Initial commit: Leihverträge App
This commit is contained in:
commit
838258aa13
14
.env.example
Normal file
14
.env.example
Normal file
@ -0,0 +1,14 @@
|
||||
# Paperless-ngx API
|
||||
PAPERLESS_BASE_URL=http://localhost:8000
|
||||
PAPERLESS_API_TOKEN=your_token_here
|
||||
|
||||
# Der exakte Name des Dokumenttyps in Paperless
|
||||
PAPERLESS_DOCTYPE_NAME=Leihvertrag
|
||||
|
||||
# EasyDB API (Platzhalter, wird später aktiviert)
|
||||
EASYDB_BASE_URL=https://easydb.example.com
|
||||
EASYDB_API_TOKEN=your_easydb_token_here
|
||||
|
||||
# App-Konfiguration
|
||||
PORT=3000
|
||||
ORIGIN=http://localhost:3000
|
||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
.env
|
||||
node_modules/
|
||||
build/
|
||||
.svelte-kit/
|
||||
85
README.md
Normal file
85
README.md
Normal file
@ -0,0 +1,85 @@
|
||||
# Leihverträge – Gutenberg-Museum
|
||||
|
||||
SvelteKit-Webapp zur Verwaltung von Leihverträgen aus Paperless-ngx,
|
||||
mit Vorbereitung für EasyDB-Integration.
|
||||
|
||||
## Schnellstart
|
||||
|
||||
```bash
|
||||
# 1. Abhängigkeiten installieren
|
||||
npm install
|
||||
|
||||
# 2. Umgebungsvariablen setzen
|
||||
cp .env.example .env
|
||||
# .env anpassen: PAPERLESS_BASE_URL, PAPERLESS_API_TOKEN, PAPERLESS_DOCTYPE_NAME
|
||||
|
||||
# 3. Entwicklungsserver starten
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
| Variable | Bedeutung | Beispiel |
|
||||
|---------------------------|----------------------------------------------------|-----------------------------|
|
||||
| `PAPERLESS_BASE_URL` | URL der Paperless-Instanz | `http://192.168.1.10:8000` |
|
||||
| `PAPERLESS_API_TOKEN` | API-Token aus Paperless → Einstellungen → API | `abc123...` |
|
||||
| `PAPERLESS_DOCTYPE_NAME` | Exakter Name des Dokumenttyps in Paperless | `Leihvertrag` |
|
||||
| `EASYDB_BASE_URL` | (Platzhalter) URL der EasyDB-Instanz | `https://easydb.museum.de` |
|
||||
| `EASYDB_API_TOKEN` | (Platzhalter) EasyDB-Token | |
|
||||
| `PORT` | HTTP-Port des Node-Servers | `3000` |
|
||||
| `ORIGIN` | Öffentliche URL (für CSRF-Schutz) | `http://server.museum.de` |
|
||||
|
||||
## Deployment auf Ubuntu
|
||||
|
||||
```bash
|
||||
chmod +x deploy.sh
|
||||
bash deploy.sh
|
||||
```
|
||||
|
||||
Der Service läuft dann unter `http://server:3000`.
|
||||
|
||||
**Nginx-Reverse-Proxy empfohlen:**
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name leihvertraege.museum.intern;
|
||||
location / {
|
||||
proxy_pass http://localhost:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── paperless.js – Paperless-ngx API Client (paginiert, gecacht)
|
||||
│ └── easydb.js – EasyDB Client (Platzhalter)
|
||||
└── routes/
|
||||
├── +page.server.js – Server-side Load (SSR)
|
||||
├── +page.svelte – Haupt-UI
|
||||
└── api/paperless/
|
||||
└── documents/
|
||||
└── +server.js – REST-Endpunkt für Client-Refresh
|
||||
```
|
||||
|
||||
## EasyDB-Integration (geplant)
|
||||
|
||||
Die App ist vorbereitet für:
|
||||
|
||||
1. **Verknüpfung**: Paperless Custom Field `easydb_id` → Link zu EasyDB-Objekt
|
||||
2. **Neuer Leihvorgang**: `src/lib/easydb.js → createLeihvorgang()` implementieren
|
||||
3. **Objektsuche**: `easydb.js → searchObjects()` für Autocomplete im Formular
|
||||
|
||||
Datenfluss geplant:
|
||||
```
|
||||
Paperless (Leihvertrag-PDF) ──easydb_id──→ EasyDB (Objekt/Leihvorgang)
|
||||
```
|
||||
|
||||
## Paperless API-Token generieren
|
||||
|
||||
Paperless → Einstellungen → API-Token → Token anzeigen/generieren
|
||||
```
|
||||
40
deploy.sh
Normal file
40
deploy.sh
Normal file
@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# deploy.sh – App auf Ubuntu-Server deployen
|
||||
# Ausführen: bash deploy.sh
|
||||
|
||||
set -e
|
||||
|
||||
APP_DIR="/opt/leihvertraege"
|
||||
SERVICE="leihvertraege"
|
||||
|
||||
echo "→ Abhängigkeiten installieren..."
|
||||
npm ci
|
||||
|
||||
echo "→ App bauen..."
|
||||
npm run build
|
||||
|
||||
echo "→ Dateien nach $APP_DIR kopieren..."
|
||||
sudo mkdir -p "$APP_DIR"
|
||||
sudo cp -r build "$APP_DIR/"
|
||||
sudo cp package.json "$APP_DIR/"
|
||||
sudo cp package-lock.json "$APP_DIR/"
|
||||
|
||||
echo "→ Produktions-Abhängigkeiten installieren..."
|
||||
cd "$APP_DIR"
|
||||
sudo npm ci --omit=dev
|
||||
|
||||
echo "→ .env prüfen..."
|
||||
if [ ! -f "$APP_DIR/.env" ]; then
|
||||
echo " ⚠ Keine .env gefunden – bitte anlegen!"
|
||||
echo " cp .env.example $APP_DIR/.env && nano $APP_DIR/.env"
|
||||
fi
|
||||
|
||||
echo "→ Systemd-Service installieren..."
|
||||
sudo cp "$OLDPWD/leihvertraege.service" /etc/systemd/system/
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable "$SERVICE"
|
||||
sudo systemctl restart "$SERVICE"
|
||||
|
||||
echo "✓ Deployment abgeschlossen."
|
||||
echo " Status: sudo systemctl status $SERVICE"
|
||||
echo " Logs: sudo journalctl -u $SERVICE -f"
|
||||
19
leihvertraege.service
Normal file
19
leihvertraege.service
Normal file
@ -0,0 +1,19 @@
|
||||
[Unit]
|
||||
Description=Leihvertraege SvelteKit App
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=www-data
|
||||
WorkingDirectory=/opt/leihvertraege
|
||||
EnvironmentFile=/opt/leihvertraege/.env
|
||||
ExecStart=/usr/bin/node build/index.js
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
# Sicherheit
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
1664
package-lock.json
generated
Normal file
1664
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "apoGM",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.60.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"svelte": "^5.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
11
src/app.html
Normal file
11
src/app.html
Normal file
@ -0,0 +1,11 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body>
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
60
src/lib/easydb.js
Normal file
60
src/lib/easydb.js
Normal file
@ -0,0 +1,60 @@
|
||||
// src/lib/easydb.js
|
||||
// EasyDB API Client – Platzhalter für spätere Integration
|
||||
|
||||
const BASE_URL = process.env.EASYDB_BASE_URL || '';
|
||||
const TOKEN = process.env.EASYDB_API_TOKEN || '';
|
||||
|
||||
/**
|
||||
* Gibt den Link zur EasyDB-Objektansicht zurück.
|
||||
* Solange BASE_URL nicht gesetzt ist, wird null zurückgegeben.
|
||||
*/
|
||||
export function easydbObjectUrl(objectId, objectType = 'objekt') {
|
||||
if (!BASE_URL || !objectId) return null;
|
||||
return `${BASE_URL}/#detail/${objectType}/${objectId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht Objekte in EasyDB nach einem Titel/Inventarnummer.
|
||||
* Platzhalter – gibt leeres Array zurück bis die Integration aktiv ist.
|
||||
*/
|
||||
export async function searchObjects(query) {
|
||||
if (!BASE_URL || !TOKEN) {
|
||||
console.warn('EasyDB nicht konfiguriert – Platzhalter aktiv.');
|
||||
return [];
|
||||
}
|
||||
|
||||
// Beispiel-Implementierung (EasyDB Search API)
|
||||
const res = await fetch(`${BASE_URL}/api/v1/search`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
search: [{ type: 'match', mode: 'fulltext', string: query }],
|
||||
objecttypes: ['objekt'],
|
||||
limit: 20
|
||||
})
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`EasyDB Fehler: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.objects || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt einen neuen Leihvorgang in EasyDB an.
|
||||
* Platzhalter – wird später implementiert.
|
||||
*/
|
||||
export async function createLeihvorgang(payload) {
|
||||
if (!BASE_URL || !TOKEN) {
|
||||
console.warn('EasyDB nicht konfiguriert – Leihvorgang nicht angelegt.');
|
||||
return { success: false, message: 'EasyDB nicht konfiguriert' };
|
||||
}
|
||||
|
||||
// TODO: Implementierung gemäß EasyDB-Datenmodell
|
||||
// const res = await fetch(`${BASE_URL}/api/v1/db/leihvorgang`, { ... });
|
||||
throw new Error('EasyDB Leihvorgang-Integration noch nicht implementiert.');
|
||||
}
|
||||
|
||||
export const easydbConfigured = Boolean(BASE_URL && TOKEN);
|
||||
92
src/lib/paperless.js
Normal file
92
src/lib/paperless.js
Normal file
@ -0,0 +1,92 @@
|
||||
// src/lib/paperless.js
|
||||
// Paperless-ngx API Client
|
||||
|
||||
const BASE_URL = process.env.PAPERLESS_BASE_URL || 'http://localhost:8000';
|
||||
const TOKEN = process.env.PAPERLESS_API_TOKEN || '';
|
||||
const DOCTYPE = process.env.PAPERLESS_DOCTYPE_NAME || 'Leihvertrag';
|
||||
|
||||
function headers() {
|
||||
return {
|
||||
'Authorization': `Token ${TOKEN}`,
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die ID des Dokumenttyps "Leihvertrag" zurück.
|
||||
* Cached das Ergebnis im Modul-Scope (gültig bis Neustart).
|
||||
*/
|
||||
let _doctypeIdCache = null;
|
||||
|
||||
export async function getDoctypeId() {
|
||||
if (_doctypeIdCache) return _doctypeIdCache;
|
||||
|
||||
const res = await fetch(`${BASE_URL}/api/document_types/?name=${encodeURIComponent(DOCTYPE)}`, {
|
||||
headers: headers()
|
||||
});
|
||||
if (!res.ok) throw new Error(`Paperless API Fehler: ${res.status} ${res.statusText}`);
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.results || data.results.length === 0) {
|
||||
throw new Error(`Dokumenttyp "${DOCTYPE}" nicht in Paperless gefunden.`);
|
||||
}
|
||||
|
||||
_doctypeIdCache = data.results[0].id;
|
||||
return _doctypeIdCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt alle Dokumente vom Typ "Leihvertrag" (paginiert).
|
||||
* Gibt ein Array aller Dokumente zurück.
|
||||
*/
|
||||
export async function fetchAllLeihvertraege() {
|
||||
const doctypeId = await getDoctypeId();
|
||||
|
||||
const documents = [];
|
||||
let nextUrl = `${BASE_URL}/api/documents/?document_type__id=${doctypeId}&ordering=-created&page_size=100`;
|
||||
|
||||
while (nextUrl) {
|
||||
const res = await fetch(nextUrl, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`Paperless API Fehler: ${res.status} ${res.statusText}`);
|
||||
const data = await res.json();
|
||||
|
||||
documents.push(...data.results);
|
||||
nextUrl = data.next;
|
||||
}
|
||||
|
||||
return documents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den direkten Link zur Paperless-Dokumentansicht zurück.
|
||||
*/
|
||||
export function paperlessDocUrl(id) {
|
||||
return `${BASE_URL}/documents/${id}/details`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den Link zum Paperless-Download zurück.
|
||||
*/
|
||||
export function paperlessDownloadUrl(id) {
|
||||
return `${BASE_URL}/api/documents/${id}/download/`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle verfügbaren Tags zurück (für Filter-UI).
|
||||
*/
|
||||
export async function fetchTags() {
|
||||
const res = await fetch(`${BASE_URL}/api/tags/?page_size=500`, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`Paperless API Fehler: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Korrespondenten zurück (für Filter-UI).
|
||||
*/
|
||||
export async function fetchCorrespondents() {
|
||||
const res = await fetch(`${BASE_URL}/api/correspondents/?page_size=500`, { headers: headers() });
|
||||
if (!res.ok) throw new Error(`Paperless API Fehler: ${res.status}`);
|
||||
const data = await res.json();
|
||||
return data.results;
|
||||
}
|
||||
49
src/routes/+page.server.js
Normal file
49
src/routes/+page.server.js
Normal file
@ -0,0 +1,49 @@
|
||||
// src/routes/+page.server.js
|
||||
import { fetchAllLeihvertraege, fetchTags, fetchCorrespondents } from '$lib/paperless.js';
|
||||
import { easydbConfigured } from '$lib/easydb.js';
|
||||
|
||||
export async function load() {
|
||||
try {
|
||||
const [documents, tags, correspondents] = await Promise.all([
|
||||
fetchAllLeihvertraege(),
|
||||
fetchTags(),
|
||||
fetchCorrespondents()
|
||||
]);
|
||||
|
||||
const tagMap = Object.fromEntries(tags.map(t => [t.id, t]));
|
||||
const corrMap = Object.fromEntries(correspondents.map(c => [c.id, c]));
|
||||
|
||||
const enriched = documents.map(doc => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
created: doc.created,
|
||||
added: doc.added,
|
||||
modified: doc.modified,
|
||||
correspondent: doc.correspondent ? (corrMap[doc.correspondent] || { id: doc.correspondent, name: '?' }) : null,
|
||||
tags: (doc.tags || []).map(tid => tagMap[tid] || { id: tid, name: '?' }),
|
||||
archived_file_name: doc.archived_file_name,
|
||||
original_file_name: doc.original_file_name,
|
||||
notes: doc.notes || [],
|
||||
easydb_id: doc.custom_fields?.easydb_id || null,
|
||||
}));
|
||||
|
||||
return {
|
||||
documents: enriched,
|
||||
allTags: tags,
|
||||
allCorrespondents: correspondents,
|
||||
easydbConfigured,
|
||||
paperlessBaseUrl: process.env.PAPERLESS_BASE_URL || 'http://localhost:8000',
|
||||
easydbBaseUrl: process.env.EASYDB_BASE_URL || '',
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
documents: [],
|
||||
allTags: [],
|
||||
allCorrespondents: [],
|
||||
easydbConfigured: false,
|
||||
paperlessBaseUrl: '',
|
||||
easydbBaseUrl: '',
|
||||
error: err.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
721
src/routes/+page.svelte
Normal file
721
src/routes/+page.svelte
Normal file
@ -0,0 +1,721 @@
|
||||
<script>
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export let data;
|
||||
|
||||
const { allTags, allCorrespondents, paperlessBaseUrl, easydbBaseUrl, easydbConfigured, error } = data;
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────
|
||||
let documents = data.documents;
|
||||
let searchQuery = '';
|
||||
let sortField = 'created';
|
||||
let sortDir = 'desc';
|
||||
let filterTag = '';
|
||||
let filterCorr = '';
|
||||
let filterYear = '';
|
||||
let viewMode = 'table'; // 'table' | 'cards'
|
||||
let loading = false;
|
||||
let selectedDoc = null; // Detail-Overlay
|
||||
|
||||
// ── Computed ──────────────────────────────────────────────────────────────
|
||||
$: years = [...new Set(documents.map(d => d.created?.slice(0,4)).filter(Boolean))].sort().reverse();
|
||||
|
||||
$: filtered = documents.filter(doc => {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const matchSearch = !q
|
||||
|| doc.title?.toLowerCase().includes(q)
|
||||
|| doc.correspondent?.name?.toLowerCase().includes(q)
|
||||
|| doc.tags?.some(t => t.name?.toLowerCase().includes(q));
|
||||
|
||||
const matchTag = !filterTag || doc.tags?.some(t => String(t.id) === filterTag);
|
||||
const matchCorr = !filterCorr || String(doc.correspondent?.id) === filterCorr;
|
||||
const matchYear = !filterYear || doc.created?.startsWith(filterYear);
|
||||
|
||||
return matchSearch && matchTag && matchCorr && matchYear;
|
||||
});
|
||||
|
||||
$: sorted = [...filtered].sort((a, b) => {
|
||||
let va = a[sortField] ?? '';
|
||||
let vb = b[sortField] ?? '';
|
||||
if (sortField === 'correspondent') {
|
||||
va = a.correspondent?.name ?? '';
|
||||
vb = b.correspondent?.name ?? '';
|
||||
}
|
||||
const cmp = va < vb ? -1 : va > vb ? 1 : 0;
|
||||
return sortDir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
function fmtDate(iso) {
|
||||
if (!iso) return '–';
|
||||
return new Date(iso).toLocaleDateString('de-DE', { day:'2-digit', month:'2-digit', year:'numeric' });
|
||||
}
|
||||
|
||||
function paperlessUrl(id) {
|
||||
return `${paperlessBaseUrl}/documents/${id}/details`;
|
||||
}
|
||||
|
||||
function easydbUrl(id) {
|
||||
if (!id || !easydbBaseUrl) return null;
|
||||
return `${easydbBaseUrl}/#detail/objekt/${id}`;
|
||||
}
|
||||
|
||||
function toggleSort(field) {
|
||||
if (sortField === field) {
|
||||
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
sortField = field;
|
||||
sortDir = 'asc';
|
||||
}
|
||||
}
|
||||
|
||||
function sortIcon(field) {
|
||||
if (sortField !== field) return '↕';
|
||||
return sortDir === 'asc' ? '↑' : '↓';
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch('/api/paperless/documents');
|
||||
const json = await res.json();
|
||||
if (json.documents) documents = json.documents;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openDetail(doc) { selectedDoc = doc; }
|
||||
function closeDetail() { selectedDoc = null; }
|
||||
|
||||
// Tastatursteuerung für Modal
|
||||
function handleKeydown(e) {
|
||||
if (e.key === 'Escape' && selectedDoc) closeDetail();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
<svelte:head>
|
||||
<title>Leihverträge – Gutenberg-Museum</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@300;400;600&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet">
|
||||
</svelte:head>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════ -->
|
||||
<!-- APP SHELL -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<div class="app">
|
||||
|
||||
<!-- Header -->
|
||||
<header class="header">
|
||||
<div class="header-inner">
|
||||
<div class="header-brand">
|
||||
<span class="brand-glyph">⁋</span>
|
||||
<div>
|
||||
<h1 class="brand-title">Leihverträge</h1>
|
||||
<p class="brand-sub">Gutenberg-Museum · Digitale Verwaltung</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<div class="stat-chip">
|
||||
<span class="stat-num">{sorted.length}</span>
|
||||
<span class="stat-label">/ {documents.length} Dok.</span>
|
||||
</div>
|
||||
<div class="view-toggle">
|
||||
<button class="btn-icon" class:active={viewMode==='table'} on:click={() => viewMode='table'} title="Tabellenansicht">
|
||||
⊟
|
||||
</button>
|
||||
<button class="btn-icon" class:active={viewMode==='cards'} on:click={() => viewMode='cards'} title="Kartenansicht">
|
||||
⊞
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-refresh" on:click={refresh} disabled={loading}>
|
||||
<span class:spinning={loading}>↺</span>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Error Banner -->
|
||||
{#if error}
|
||||
<div class="error-banner">
|
||||
<strong>⚠ Verbindungsfehler:</strong> {error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- EasyDB-Status Banner -->
|
||||
{#if !easydbConfigured}
|
||||
<div class="info-banner">
|
||||
<strong>EasyDB nicht konfiguriert</strong> – Links zu Objekten sind Platzhalter.
|
||||
Bitte <code>EASYDB_BASE_URL</code> und <code>EASYDB_API_TOKEN</code> in der <code>.env</code> setzen.
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="main">
|
||||
|
||||
<!-- ── Filter-Leiste ─────────────────────────────────────────────────── -->
|
||||
<div class="filter-bar">
|
||||
<div class="search-wrap">
|
||||
<span class="search-icon">⌕</span>
|
||||
<input
|
||||
class="search-input"
|
||||
type="search"
|
||||
placeholder="Titel, Korrespondent, Tag …"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
{#if searchQuery}
|
||||
<button class="clear-btn" on:click={() => searchQuery=''}>✕</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<select class="filter-select" bind:value={filterCorr}>
|
||||
<option value="">Alle Korrespondenten</option>
|
||||
{#each allCorrespondents as c}
|
||||
<option value={String(c.id)}>{c.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select class="filter-select" bind:value={filterTag}>
|
||||
<option value="">Alle Tags</option>
|
||||
{#each allTags as t}
|
||||
<option value={String(t.id)}>{t.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select class="filter-select" bind:value={filterYear}>
|
||||
<option value="">Alle Jahre</option>
|
||||
{#each years as y}
|
||||
<option value={y}>{y}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if searchQuery || filterCorr || filterTag || filterYear}
|
||||
<button class="btn-clear-all" on:click={() => { searchQuery=''; filterCorr=''; filterTag=''; filterYear=''; }}>
|
||||
Filter löschen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Tabellenansicht ───────────────────────────────────────────────── -->
|
||||
{#if viewMode === 'table'}
|
||||
<div class="table-wrap">
|
||||
<table class="doc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" on:click={() => toggleSort('title')}>
|
||||
Titel <span class="sort-icon">{sortIcon('title')}</span>
|
||||
</th>
|
||||
<th class="sortable" on:click={() => toggleSort('correspondent')}>
|
||||
Korrespondent <span class="sort-icon">{sortIcon('correspondent')}</span>
|
||||
</th>
|
||||
<th class="sortable" on:click={() => toggleSort('created')}>
|
||||
Datum <span class="sort-icon">{sortIcon('created')}</span>
|
||||
</th>
|
||||
<th>Tags</th>
|
||||
<th class="th-actions">Verknüpfungen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted as doc (doc.id)}
|
||||
<tr class="doc-row" on:click={() => openDetail(doc)}>
|
||||
<td class="td-title">
|
||||
<span class="doc-id">#{doc.id}</span>
|
||||
{doc.title || '(kein Titel)'}
|
||||
</td>
|
||||
<td class="td-corr">
|
||||
{doc.correspondent?.name || '–'}
|
||||
</td>
|
||||
<td class="td-date">{fmtDate(doc.created)}</td>
|
||||
<td class="td-tags">
|
||||
{#each (doc.tags || []).slice(0,3) as tag}
|
||||
<span class="tag-pill">{tag.name}</span>
|
||||
{/each}
|
||||
{#if doc.tags?.length > 3}
|
||||
<span class="tag-more">+{doc.tags.length - 3}</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="td-actions" on:click|stopPropagation>
|
||||
<a
|
||||
href={paperlessUrl(doc.id)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="link-btn paperless-link"
|
||||
title="In Paperless öffnen"
|
||||
>
|
||||
P
|
||||
</a>
|
||||
{#if easydbConfigured && doc.easydb_id}
|
||||
<a
|
||||
href={easydbUrl(doc.easydb_id)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="link-btn easydb-link"
|
||||
title="In EasyDB öffnen"
|
||||
>
|
||||
E
|
||||
</a>
|
||||
{:else}
|
||||
<button
|
||||
class="link-btn easydb-link placeholder"
|
||||
title="EasyDB-Objekt noch nicht verknüpft"
|
||||
on:click={() => openDetail(doc)}
|
||||
>
|
||||
E
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
<tr>
|
||||
<td colspan="5" class="no-results">Keine Dokumente gefunden.</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- ── Kartenansicht ─────────────────────────────────────────────────── -->
|
||||
{:else}
|
||||
<div class="cards-grid">
|
||||
{#each sorted as doc (doc.id)}
|
||||
<div class="doc-card" on:click={() => openDetail(doc)}>
|
||||
<div class="card-header">
|
||||
<span class="doc-id-small">#{doc.id}</span>
|
||||
<span class="card-date">{fmtDate(doc.created)}</span>
|
||||
</div>
|
||||
<h3 class="card-title">{doc.title || '(kein Titel)'}</h3>
|
||||
{#if doc.correspondent}
|
||||
<p class="card-corr">{doc.correspondent.name}</p>
|
||||
{/if}
|
||||
<div class="card-tags">
|
||||
{#each (doc.tags || []).slice(0, 4) as tag}
|
||||
<span class="tag-pill">{tag.name}</span>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="card-footer" on:click|stopPropagation>
|
||||
<a href={paperlessUrl(doc.id)} target="_blank" rel="noopener" class="link-btn paperless-link" title="Paperless">P</a>
|
||||
{#if easydbConfigured && doc.easydb_id}
|
||||
<a href={easydbUrl(doc.easydb_id)} target="_blank" rel="noopener" class="link-btn easydb-link" title="EasyDB">E</a>
|
||||
{:else}
|
||||
<button class="link-btn easydb-link placeholder" title="EasyDB nicht verknüpft">E</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="no-results">Keine Dokumente gefunden.</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════ -->
|
||||
<!-- DETAIL-OVERLAY -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
{#if selectedDoc}
|
||||
<div class="overlay-backdrop" on:click={closeDetail}>
|
||||
<div class="overlay-panel" on:click|stopPropagation role="dialog" aria-modal="true">
|
||||
|
||||
<button class="overlay-close" on:click={closeDetail}>✕</button>
|
||||
|
||||
<div class="overlay-header">
|
||||
<span class="doc-id">#{selectedDoc.id}</span>
|
||||
<h2 class="overlay-title">{selectedDoc.title || '(kein Titel)'}</h2>
|
||||
</div>
|
||||
|
||||
<div class="overlay-grid">
|
||||
<div class="meta-group">
|
||||
<label>Korrespondent</label>
|
||||
<p>{selectedDoc.correspondent?.name || '–'}</p>
|
||||
</div>
|
||||
<div class="meta-group">
|
||||
<label>Dokumentdatum</label>
|
||||
<p>{fmtDate(selectedDoc.created)}</p>
|
||||
</div>
|
||||
<div class="meta-group">
|
||||
<label>Hinzugefügt</label>
|
||||
<p>{fmtDate(selectedDoc.added)}</p>
|
||||
</div>
|
||||
<div class="meta-group">
|
||||
<label>Zuletzt geändert</label>
|
||||
<p>{fmtDate(selectedDoc.modified)}</p>
|
||||
</div>
|
||||
{#if selectedDoc.archived_file_name}
|
||||
<div class="meta-group wide">
|
||||
<label>Dateiname</label>
|
||||
<p class="mono">{selectedDoc.archived_file_name}</p>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="meta-group wide">
|
||||
<label>Tags</label>
|
||||
<div class="tag-list">
|
||||
{#each selectedDoc.tags as tag}
|
||||
<span class="tag-pill">{tag.name}</span>
|
||||
{:else}
|
||||
<span class="dim">Keine Tags</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedDoc.notes?.length}
|
||||
<div class="meta-group wide">
|
||||
<label>Notizen</label>
|
||||
{#each selectedDoc.notes as note}
|
||||
<p class="note-text">{note.note}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- EasyDB-Verknüpfung -->
|
||||
<div class="easydb-section">
|
||||
<h3>EasyDB-Objekt</h3>
|
||||
{#if easydbConfigured && selectedDoc.easydb_id}
|
||||
<a href={easydbUrl(selectedDoc.easydb_id)} target="_blank" rel="noopener" class="easydb-full-link">
|
||||
Objekt #{selectedDoc.easydb_id} in EasyDB öffnen →
|
||||
</a>
|
||||
{:else}
|
||||
<p class="dim">
|
||||
{#if !easydbConfigured}
|
||||
EasyDB ist nicht konfiguriert. Bitte <code>.env</code> anpassen.
|
||||
{:else}
|
||||
Dieses Dokument ist noch nicht mit einem EasyDB-Objekt verknüpft.
|
||||
<br><em>(Verknüpfung zukünftig über <code>custom_fields.easydb_id</code> in Paperless)</em>
|
||||
{/if}
|
||||
</p>
|
||||
<button class="btn-placeholder" disabled title="Noch nicht implementiert">
|
||||
+ Neuen Leihvorgang in EasyDB anlegen
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="overlay-footer">
|
||||
<a
|
||||
href={paperlessUrl(selectedDoc.id)}
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="btn-primary"
|
||||
>
|
||||
In Paperless öffnen →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════ -->
|
||||
<!-- STYLES -->
|
||||
<!-- ═══════════════════════════════════════════════════════════════════════ -->
|
||||
|
||||
<style>
|
||||
/* ── Variablen ──────────────────────────────────────────────────── */
|
||||
:global(*) { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:global(body) {
|
||||
background: #ffffff;
|
||||
color: #1a1a1a;
|
||||
font-family: 'Josefin Sans', 'Futura', 'Century Gothic', sans-serif;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ── App Shell ──────────────────────────────────────────────────── */
|
||||
.app { display: flex; flex-direction: column; min-height: 100vh; }
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────── */
|
||||
.header {
|
||||
border-bottom: 1px solid #1a1a1a;
|
||||
background: #0e0d0b;
|
||||
position: sticky; top: 0; z-index: 40;
|
||||
}
|
||||
.header-inner {
|
||||
max-width: 1400px; margin: 0 auto;
|
||||
padding: 14px 24px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.header-brand { display: flex; align-items: center; gap: 14px; }
|
||||
.brand-glyph {
|
||||
font-size: 2.4rem;
|
||||
color: #c9a84c;
|
||||
font-family: 'Josefin Sans', sans-serif;
|
||||
line-height: 1;
|
||||
font-weight: 300;
|
||||
}
|
||||
.brand-title {
|
||||
font-family: 'Josefin Sans', 'Futura', sans-serif;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
color: #f0e8d0;
|
||||
letter-spacing: 0.15em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.brand-sub {
|
||||
font-size: 9px;
|
||||
color: #5c5645;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
font-weight: 300;
|
||||
}
|
||||
.header-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
.stat-chip {
|
||||
background: #1a1812;
|
||||
border: 1px solid #2a2720;
|
||||
border-radius: 2px;
|
||||
padding: 4px 10px;
|
||||
font-size: 11px;
|
||||
color: #8a8070;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.stat-num { color: #c9a84c; font-weight: 600; margin-right: 2px; }
|
||||
|
||||
.view-toggle { display: flex; border: 1px solid #2a2720; border-radius: 2px; overflow: hidden; }
|
||||
.btn-icon {
|
||||
background: none; border: none; cursor: pointer;
|
||||
padding: 5px 10px; color: #5c5645; font-size: 15px;
|
||||
transition: background .15s, color .15s;
|
||||
}
|
||||
.btn-icon:hover { background: #1a1812; color: #c9a84c; }
|
||||
.btn-icon.active { background: #1a1812; color: #c9a84c; }
|
||||
|
||||
.btn-refresh {
|
||||
background: none; border: 1px solid #2a2720; border-radius: 2px;
|
||||
cursor: pointer; padding: 5px 12px; color: #8a8070;
|
||||
font-family: 'Josefin Sans', sans-serif; font-size: 11px;
|
||||
letter-spacing: 0.1em; text-transform: uppercase;
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
.btn-refresh:hover:not(:disabled) { border-color: #c9a84c; color: #c9a84c; }
|
||||
.btn-refresh:disabled { opacity: .4; cursor: not-allowed; }
|
||||
.spinning { display: inline-block; animation: spin .7s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
/* ── Banners ─────────────────────────────────────────────────────── */
|
||||
.error-banner {
|
||||
background: #fff5f5; border-bottom: 1px solid #fccaca;
|
||||
padding: 10px 24px; color: #c0392b; font-size: 12px;
|
||||
}
|
||||
.info-banner {
|
||||
background: #fffdf0; border-bottom: 1px solid #f0e8c0;
|
||||
padding: 8px 24px; color: #957a30; font-size: 11px;
|
||||
}
|
||||
.info-banner code { color: #b8860b; background: #f5f0e0; padding: 1px 4px; border-radius: 2px; }
|
||||
|
||||
/* ── Main ────────────────────────────────────────────────────────── */
|
||||
.main { flex: 1; max-width: 1400px; width: 100%; margin: 0 auto; padding: 20px 24px 40px; }
|
||||
|
||||
/* ── Filter-Bar ──────────────────────────────────────────────────── */
|
||||
.filter-bar {
|
||||
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.search-wrap {
|
||||
position: relative; flex: 1 1 240px;
|
||||
display: flex; align-items: center;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute; left: 10px; color: #aaaaaa; font-size: 16px; pointer-events: none;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%; background: #ffffff; border: 1px solid #e0e0e0; border-radius: 3px;
|
||||
padding: 7px 10px 7px 30px; color: #1a1a1a;
|
||||
font-family: 'DM Mono', 'Courier New', monospace; font-size: 12px;
|
||||
outline: none; transition: border-color .15s;
|
||||
}
|
||||
.search-input:focus { border-color: #b8860b; }
|
||||
.search-input::placeholder { color: #cccccc; }
|
||||
.clear-btn {
|
||||
position: absolute; right: 8px; background: none; border: none;
|
||||
cursor: pointer; color: #aaaaaa; font-size: 11px; padding: 2px 4px;
|
||||
}
|
||||
.clear-btn:hover { color: #b8860b; }
|
||||
|
||||
.filter-select {
|
||||
background: #ffffff; border: 1px solid #e0e0e0; border-radius: 3px;
|
||||
padding: 7px 10px; color: #555555;
|
||||
font-family: 'DM Mono', 'Courier New', monospace; font-size: 11px;
|
||||
outline: none; cursor: pointer; flex: 0 1 180px;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
.filter-select:focus { border-color: #b8860b; }
|
||||
|
||||
.btn-clear-all {
|
||||
background: none; border: 1px solid #ffcccc; border-radius: 3px;
|
||||
padding: 7px 12px; color: #cc5555; font-size: 11px; cursor: pointer;
|
||||
font-family: 'DM Mono', 'Courier New', monospace;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
.btn-clear-all:hover { border-color: #c0392b; color: #c0392b; }
|
||||
|
||||
/* ── Table ───────────────────────────────────────────────────────── */
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
border: 1px solid #e8e8e8; border-radius: 4px;
|
||||
}
|
||||
.doc-table {
|
||||
width: 100%; border-collapse: collapse;
|
||||
}
|
||||
.doc-table thead {
|
||||
background: #f9f9f9; border-bottom: 1px solid #e8e8e8;
|
||||
}
|
||||
.doc-table th {
|
||||
padding: 10px 14px; text-align: left;
|
||||
font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase;
|
||||
color: #aaaaaa; font-weight: 400; white-space: nowrap;
|
||||
}
|
||||
.sortable { cursor: pointer; user-select: none; }
|
||||
.sortable:hover { color: #b8860b; }
|
||||
.sort-icon { margin-left: 4px; color: #cccccc; }
|
||||
.th-actions { text-align: center; }
|
||||
|
||||
.doc-row {
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
cursor: pointer; transition: background .1s;
|
||||
}
|
||||
.doc-row:hover { background: #fafafa; }
|
||||
.doc-row:last-child { border-bottom: none; }
|
||||
|
||||
.doc-table td { padding: 10px 14px; vertical-align: middle; }
|
||||
|
||||
.td-title { font-size: 12px; color: #1a1a1a; max-width: 380px; }
|
||||
.doc-id { color: #cccccc; font-size: 10px; margin-right: 6px; }
|
||||
.doc-id-small { color: #cccccc; font-size: 10px; }
|
||||
|
||||
.td-corr { color: #666666; font-size: 11px; }
|
||||
.td-date { color: #888888; font-size: 11px; white-space: nowrap; }
|
||||
.td-tags { max-width: 240px; }
|
||||
.td-actions { text-align: center; display: flex; gap: 6px; justify-content: center; align-items: center; }
|
||||
|
||||
.no-results { text-align: center; padding: 40px; color: #cccccc; }
|
||||
|
||||
/* ── Tag Pills ───────────────────────────────────────────────────── */
|
||||
.tag-pill {
|
||||
display: inline-block;
|
||||
background: #f5f5f5; border: 1px solid #e8e8e8;
|
||||
border-radius: 2px; padding: 1px 6px;
|
||||
font-size: 10px; color: #888888; margin: 1px 2px 1px 0;
|
||||
}
|
||||
.tag-more { color: #cccccc; font-size: 10px; margin-left: 2px; }
|
||||
|
||||
/* ── Link Buttons ────────────────────────────────────────────────── */
|
||||
.link-btn {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px; border-radius: 3px;
|
||||
font-size: 11px; font-weight: 500; text-decoration: none;
|
||||
font-family: 'DM Mono', 'Courier New', monospace;
|
||||
transition: transform .1s, opacity .15s;
|
||||
cursor: pointer; border: none;
|
||||
}
|
||||
.link-btn:hover { transform: scale(1.1); }
|
||||
|
||||
.paperless-link { background: #f0faf0; color: #3a9a30; border: 1px solid #c8e8c8; }
|
||||
.paperless-link:hover { background: #e0f5e0; border-color: #3a9a30; }
|
||||
|
||||
.easydb-link { background: #f0f4ff; color: #3060c0; border: 1px solid #c8d4f0; }
|
||||
.easydb-link:hover:not(.placeholder) { background: #e0ecff; border-color: #3060c0; }
|
||||
.easydb-link.placeholder { color: #cccccc; border-color: #eeeeee; background: #fafafa; cursor: default; }
|
||||
.easydb-link.placeholder:hover { transform: none; }
|
||||
|
||||
/* ── Cards ───────────────────────────────────────────────────────── */
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.doc-card {
|
||||
background: #ffffff; border: 1px solid #e8e8e8; border-radius: 4px;
|
||||
padding: 16px; cursor: pointer;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
display: flex; flex-direction: column; gap: 8px;
|
||||
}
|
||||
.doc-card:hover { border-color: #d0d0d0; box-shadow: 0 2px 8px rgba(0,0,0,.06); }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: center; }
|
||||
.card-date { color: #aaaaaa; font-size: 10px; }
|
||||
.card-title { font-family: 'Josefin Sans', 'Futura', sans-serif; font-size: 13px; font-weight: 600; letter-spacing: 0.05em; color: #1a1a1a; line-height: 1.4; text-transform: uppercase; }
|
||||
.card-corr { color: #888888; font-size: 11px; }
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 2px; flex: 1; }
|
||||
.card-footer { display: flex; gap: 6px; padding-top: 6px; border-top: 1px solid #f0f0f0; margin-top: auto; }
|
||||
|
||||
/* ── Overlay ─────────────────────────────────────────────────────── */
|
||||
.overlay-backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.4);
|
||||
z-index: 100; display: flex; align-items: center; justify-content: center;
|
||||
padding: 24px;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
.overlay-panel {
|
||||
background: #ffffff; border: 1px solid #e8e8e8; border-radius: 6px;
|
||||
max-width: 680px; width: 100%; max-height: 90vh;
|
||||
overflow-y: auto; padding: 28px; position: relative;
|
||||
box-shadow: 0 8px 40px rgba(0,0,0,.12);
|
||||
animation: slideUp .2s ease;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(12px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
.overlay-close {
|
||||
position: absolute; top: 16px; right: 16px;
|
||||
background: none; border: none; cursor: pointer;
|
||||
color: #aaaaaa; font-size: 14px; padding: 4px 6px;
|
||||
transition: color .15s;
|
||||
}
|
||||
.overlay-close:hover { color: #1a1a1a; }
|
||||
.overlay-header { margin-bottom: 20px; }
|
||||
.overlay-title {
|
||||
font-family: 'Josefin Sans', 'Futura', sans-serif; font-size: 1.1rem;
|
||||
color: #1a1a1a; font-weight: 600; line-height: 1.4;
|
||||
letter-spacing: 0.08em; text-transform: uppercase;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.overlay-grid {
|
||||
display: grid; grid-template-columns: 1fr 1fr;
|
||||
gap: 16px; margin-bottom: 24px;
|
||||
}
|
||||
.meta-group label {
|
||||
display: block; font-size: 9px; letter-spacing: 0.12em;
|
||||
text-transform: uppercase; color: #aaaaaa; margin-bottom: 3px;
|
||||
}
|
||||
.meta-group p { color: #1a1a1a; font-size: 12px; }
|
||||
.meta-group.wide { grid-column: 1 / -1; }
|
||||
.tag-list { display: flex; flex-wrap: wrap; gap: 3px; }
|
||||
.mono { font-family: 'DM Mono', 'Courier New', monospace; font-size: 11px; word-break: break-all; }
|
||||
.dim { color: #cccccc; font-size: 11px; }
|
||||
.note-text { color: #666666; font-size: 11px; margin-top: 4px; }
|
||||
|
||||
.easydb-section {
|
||||
background: #f5f8ff; border: 1px solid #dde6f8; border-radius: 4px;
|
||||
padding: 16px; margin-bottom: 20px;
|
||||
}
|
||||
.easydb-section h3 {
|
||||
font-size: 10px; letter-spacing: 0.12em; text-transform: uppercase;
|
||||
color: #8090bb; margin-bottom: 10px;
|
||||
}
|
||||
.easydb-full-link {
|
||||
color: #3060c0; text-decoration: none; font-size: 12px;
|
||||
transition: color .15s;
|
||||
}
|
||||
.easydb-full-link:hover { color: #1040a0; text-decoration: underline; }
|
||||
.btn-placeholder {
|
||||
margin-top: 10px; background: #f5f5f5; border: 1px dashed #dddddd;
|
||||
border-radius: 3px; padding: 8px 14px; color: #bbbbbb; font-size: 11px;
|
||||
cursor: not-allowed; font-family: 'DM Mono', 'Courier New', monospace;
|
||||
}
|
||||
|
||||
.overlay-footer { display: flex; justify-content: flex-end; }
|
||||
.btn-primary {
|
||||
background: #f0faf0; border: 1px solid #3a9a30; border-radius: 3px;
|
||||
padding: 8px 18px; color: #3a9a30; font-size: 12px; text-decoration: none;
|
||||
font-family: 'DM Mono', 'Courier New', monospace;
|
||||
transition: background .15s;
|
||||
}
|
||||
.btn-primary:hover { background: #e0f5e0; }
|
||||
</style>
|
||||
40
src/routes/api/paperless/documents/+server.js
Normal file
40
src/routes/api/paperless/documents/+server.js
Normal file
@ -0,0 +1,40 @@
|
||||
// src/routes/api/paperless/documents/+server.js
|
||||
// API-Endpunkt: gibt alle Leihverträge als JSON zurück
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { fetchAllLeihvertraege, fetchTags, fetchCorrespondents } from '$lib/paperless.js';
|
||||
|
||||
export async function GET({ url }) {
|
||||
try {
|
||||
const [documents, tags, correspondents] = await Promise.all([
|
||||
fetchAllLeihvertraege(),
|
||||
fetchTags(),
|
||||
fetchCorrespondents()
|
||||
]);
|
||||
|
||||
// Tag- und Korrespondenten-Maps für schnelles Lookup
|
||||
const tagMap = Object.fromEntries(tags.map(t => [t.id, t]));
|
||||
const corrMap = Object.fromEntries(correspondents.map(c => [c.id, c]));
|
||||
|
||||
// Dokumente anreichern
|
||||
const enriched = documents.map(doc => ({
|
||||
id: doc.id,
|
||||
title: doc.title,
|
||||
created: doc.created,
|
||||
added: doc.added,
|
||||
modified: doc.modified,
|
||||
correspondent: doc.correspondent ? (corrMap[doc.correspondent] || { id: doc.correspondent, name: '?' }) : null,
|
||||
tags: (doc.tags || []).map(tid => tagMap[tid] || { id: tid, name: '?' }),
|
||||
archived_file_name: doc.archived_file_name,
|
||||
original_file_name: doc.original_file_name,
|
||||
notes: doc.notes || [],
|
||||
// EasyDB-Verknüpfung: später aus Custom-Feldern oder Metadaten lesen
|
||||
easydb_id: doc.custom_fields?.easydb_id || null,
|
||||
}));
|
||||
|
||||
return json({ documents: enriched, total: enriched.length });
|
||||
} catch (err) {
|
||||
console.error('Fehler beim Laden der Leihverträge:', err);
|
||||
return json({ error: err.message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
12
svelte.config.js
Normal file
12
svelte.config.js
Normal file
@ -0,0 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build'
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
6
vite.config.js
Normal file
6
vite.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user