commit
This commit is contained in:
parent
52a2ae9a31
commit
a5c4ac60ea
@ -1,3 +1,17 @@
|
|||||||
module github.com/scanner/backend
|
module github.com/scanner/backend
|
||||||
|
|
||||||
go 1.20
|
go 1.22
|
||||||
|
|
||||||
|
toolchain go1.23.7
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||||
|
github.com/hhrutter/tiff v1.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/pdfcpu/pdfcpu v0.9.1 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
golang.org/x/image v0.21.0 // indirect
|
||||||
|
golang.org/x/text v0.19.0 // indirect
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
)
|
||||||
|
|||||||
23
backend/go.sum
Normal file
23
backend/go.sum
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||||
|
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||||
|
github.com/hhrutter/tiff v1.0.1 h1:MIus8caHU5U6823gx7C6jrfoEvfSTGtEFRiM8/LOzC0=
|
||||||
|
github.com/hhrutter/tiff v1.0.1/go.mod h1:zU/dNgDm0cMIa8y8YwcYBeuEEveI4B0owqHyiPpJPHc=
|
||||||
|
github.com/jung-kurt/gofpdf/v2 v2.0.0/go.mod h1:59m4YstrsXqnwOx8NOVIk6k0XqfrRPkiunj4eAevyaY=
|
||||||
|
github.com/jung-kurt/gofpdf/v2 v2.17.3 h1:otZXZby2gXJ7uU6pzprXHq/R57lsHLi0WtH79VabWxY=
|
||||||
|
github.com/jung-kurt/gofpdf/v2 v2.17.3/go.mod h1:Qx8ZNg4cNsO5i6uLDiBngnm+ii/FjtAqjRNO6drsoYU=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/pdfcpu/pdfcpu v0.9.1 h1:q8/KlBdHjkE7ZJU4ofhKG5Rjf7M6L324CVM6BMDySao=
|
||||||
|
github.com/pdfcpu/pdfcpu v0.9.1/go.mod h1:fVfOloBzs2+W2VJCCbq60XIxc3yJHAZ0Gahv1oO0gyI=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
golang.org/x/image v0.21.0 h1:c5qV36ajHpdj4Qi0GnE0jUc/yuo33OLFaa0d+crTD5s=
|
||||||
|
golang.org/x/image v0.21.0/go.mod h1:vUbsLavqK/W303ZroQQVKQ+Af3Yl6Uz1Ppu5J/cLz78=
|
||||||
|
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
|
||||||
|
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||||
|
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||||
57
backend/handlers/file_handlers.go
Normal file
57
backend/handlers/file_handlers.go
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/scanner/backend/middleware"
|
||||||
|
"github.com/scanner/backend/models"
|
||||||
|
"github.com/scanner/backend/services"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileHandler struct {
|
||||||
|
fileConverter *services.FileConverter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileHandler(fileConverter *services.FileConverter) *FileHandler {
|
||||||
|
return &FileHandler{fileConverter: fileConverter}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *FileHandler) MergeFiles(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
middleware.ApplyCors(w, r)
|
||||||
|
|
||||||
|
var mergeRequest models.MergeRequest
|
||||||
|
err := json.NewDecoder(r.Body).Decode(&mergeRequest)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mergeRequest.Filenames) < 2 {
|
||||||
|
http.Error(w, "At least two files are required for merging", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
files := []string{}
|
||||||
|
for _, filename := range mergeRequest.Filenames {
|
||||||
|
files = append(files, "/home/backend/var/documents/"+filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedFilename, err := h.fileConverter.MergeFiles(files)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to merge files: "+err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
response := map[string]string{
|
||||||
|
"mergedFile": "/files/" + mergedFilename,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
@ -12,9 +12,11 @@ import (
|
|||||||
func main() {
|
func main() {
|
||||||
// Initialize services
|
// Initialize services
|
||||||
scannerService := services.NewScannerService()
|
scannerService := services.NewScannerService()
|
||||||
|
fileConverter := services.NewFileConverter()
|
||||||
|
|
||||||
// Initialize handlers
|
// Initialize handlers
|
||||||
scanHandler := handlers.NewScanHandler(scannerService)
|
scanHandler := handlers.NewScanHandler(scannerService)
|
||||||
|
fileHandler := handlers.NewFileHandler(fileConverter)
|
||||||
|
|
||||||
// Set up routes with middleware
|
// Set up routes with middleware
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
@ -24,6 +26,7 @@ func main() {
|
|||||||
mux.HandleFunc("/scan/abort", scanHandler.AbortScan)
|
mux.HandleFunc("/scan/abort", scanHandler.AbortScan)
|
||||||
mux.HandleFunc("/scan/status", scanHandler.GetScanStatus)
|
mux.HandleFunc("/scan/status", scanHandler.GetScanStatus)
|
||||||
mux.HandleFunc("/documents", scanHandler.GetDocuments)
|
mux.HandleFunc("/documents", scanHandler.GetDocuments)
|
||||||
|
mux.HandleFunc("/documents/merge", fileHandler.MergeFiles)
|
||||||
|
|
||||||
fileServer := http.FileServer(http.Dir("/home/backend/var/documents"))
|
fileServer := http.FileServer(http.Dir("/home/backend/var/documents"))
|
||||||
mux.Handle("/files/", http.StripPrefix("/files", fileServer))
|
mux.Handle("/files/", http.StripPrefix("/files", fileServer))
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
// Document represents a scanned document
|
|
||||||
type Document struct {
|
type Document struct {
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MergeRequest struct {
|
||||||
|
Filenames []string `json:"filenames"`
|
||||||
|
}
|
||||||
|
|||||||
37
backend/services/file_converter.go
Normal file
37
backend/services/file_converter.go
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
type FileConverter struct {
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileConverter() *FileConverter {
|
||||||
|
return &FileConverter{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *FileConverter) MergeFiles(filenames []string) (string, error) {
|
||||||
|
timestamp := time.Now().Format("20060102-150405")
|
||||||
|
mergedFilename := "merged-" + timestamp + ".pdf"
|
||||||
|
mergedFile := "/home/backend/var/documents/" + mergedFilename
|
||||||
|
|
||||||
|
outputDir := filepath.Dir(mergedFile)
|
||||||
|
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = os.Remove(mergedFile)
|
||||||
|
|
||||||
|
config := api.LoadConfiguration()
|
||||||
|
|
||||||
|
if err := api.MergeCreateFile(filenames, mergedFile, false, config); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return mergedFilename, nil
|
||||||
|
}
|
||||||
@ -42,7 +42,7 @@ func listDevices() ([]string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func startScan(outputFile string) (string, error) {
|
func startScan(outputFile string) (string, error) {
|
||||||
result, err := scanImage("-d", "escl:https://printer:443", "--format", "png", "--output-file", outputFile)
|
result, err := scanImage("-d", "escl:https://printer:443", "--format", "pdf", "--output-file", outputFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("error scanning: %v", err)
|
return "", fmt.Errorf("error scanning: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,12 +9,10 @@ import (
|
|||||||
"github.com/scanner/backend/models"
|
"github.com/scanner/backend/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ScannerService handles scanner operations
|
|
||||||
type ScannerService struct {
|
type ScannerService struct {
|
||||||
state models.ScannerState
|
state models.ScannerState
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewScannerService creates a new scanner service
|
|
||||||
func NewScannerService() *ScannerService {
|
func NewScannerService() *ScannerService {
|
||||||
return &ScannerService{
|
return &ScannerService{
|
||||||
state: models.ScannerState{
|
state: models.ScannerState{
|
||||||
@ -23,7 +21,6 @@ func NewScannerService() *ScannerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetScanStatus returns the current scan status
|
|
||||||
func (s *ScannerService) IsScanning() bool {
|
func (s *ScannerService) IsScanning() bool {
|
||||||
s.state.Mu.Lock()
|
s.state.Mu.Lock()
|
||||||
state := s.state.IsScanning
|
state := s.state.IsScanning
|
||||||
@ -32,7 +29,6 @@ func (s *ScannerService) IsScanning() bool {
|
|||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartScan begins a new scanning process
|
|
||||||
func (s *ScannerService) StartScan() (bool, error) {
|
func (s *ScannerService) StartScan() (bool, error) {
|
||||||
s.state.Mu.Lock()
|
s.state.Mu.Lock()
|
||||||
defer s.state.Mu.Unlock()
|
defer s.state.Mu.Unlock()
|
||||||
@ -47,7 +43,6 @@ func (s *ScannerService) StartScan() (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AbortScan stops an in-progress scan
|
|
||||||
func (s *ScannerService) AbortScan() (bool, error) {
|
func (s *ScannerService) AbortScan() (bool, error) {
|
||||||
s.state.Mu.Lock()
|
s.state.Mu.Lock()
|
||||||
defer s.state.Mu.Unlock()
|
defer s.state.Mu.Unlock()
|
||||||
@ -60,7 +55,6 @@ func (s *ScannerService) AbortScan() (bool, error) {
|
|||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDocuments returns all scanned documents
|
|
||||||
func (s *ScannerService) GetDocuments() []models.Document {
|
func (s *ScannerService) GetDocuments() []models.Document {
|
||||||
const outputDir = "/home/backend/var/documents"
|
const outputDir = "/home/backend/var/documents"
|
||||||
|
|
||||||
@ -76,7 +70,7 @@ func (s *ScannerService) GetDocuments() []models.Document {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filename := file.Name()
|
filename := file.Name()
|
||||||
if !strings.HasPrefix(filename, "scan_") || !strings.HasSuffix(filename, ".jpg") {
|
if !strings.HasSuffix(filename, ".pdf") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,7 +94,7 @@ func (s *ScannerService) scan() {
|
|||||||
|
|
||||||
outputDir := "/home/backend/var/documents"
|
outputDir := "/home/backend/var/documents"
|
||||||
timestamp := time.Now().Format("20060102150405")
|
timestamp := time.Now().Format("20060102150405")
|
||||||
outputFile := fmt.Sprintf("%s/scan_%s.jpg", outputDir, timestamp)
|
outputFile := fmt.Sprintf("%s/scan_%s.pdf", outputDir, timestamp)
|
||||||
|
|
||||||
_, err := startScan(outputFile)
|
_, err := startScan(outputFile)
|
||||||
|
|
||||||
|
|||||||
32
docker-compose.server.yml
Normal file
32
docker-compose.server.yml
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
hostname: scanner-backend
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8089:80"
|
||||||
|
extra_hosts:
|
||||||
|
- "printer:192.168.178.36"
|
||||||
|
networks:
|
||||||
|
- printer_network
|
||||||
|
- proxy
|
||||||
|
cap_add:
|
||||||
|
- NET_ADMIN
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
hostname: scanner-frontend
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
ports:
|
||||||
|
- "8090:80"
|
||||||
|
networks:
|
||||||
|
- printer_network
|
||||||
|
- proxy
|
||||||
|
|
||||||
|
networks:
|
||||||
|
printer_network:
|
||||||
|
driver: bridge
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
@ -5,76 +5,251 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Document Scanner</title>
|
<title>Document Scanner</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
:root {
|
||||||
font-family: Arial, sans-serif;
|
--primary-color: #4285f4;
|
||||||
max-width: 800px;
|
--primary-hover: #3367d6;
|
||||||
margin: 0 auto;
|
--light-gray: #f5f5f5;
|
||||||
padding: 20px;
|
--border-color: #e0e0e0;
|
||||||
|
--shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
--radius: 8px;
|
||||||
|
--spacing: 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 30px;
|
||||||
|
background-color: #fafafa;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2 {
|
||||||
|
color: #1a1a1a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: var(--spacing);
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
margin-top: var(--spacing);
|
||||||
|
margin-bottom: calc(var(--spacing) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 20px;
|
gap: var(--spacing);
|
||||||
|
background-color: white;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: var(--spacing);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
}
|
}
|
||||||
|
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
padding: 10px 20px;
|
padding: 12px 24px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius);
|
||||||
background-color: #4285f4;
|
background-color: var(--primary-color);
|
||||||
color: white;
|
color: white;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.3s;
|
transition: all 0.3s ease;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 1rem;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
button:hover {
|
button:hover {
|
||||||
background-color: #3367d6;
|
background-color: var(--primary-hover);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
button:disabled {
|
button:disabled {
|
||||||
background-color: #cccccc;
|
background-color: #cccccc;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
box-shadow: none;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-grid {
|
.document-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-card {
|
.document-card {
|
||||||
border: 1px solid #ddd;
|
position: relative;
|
||||||
border-radius: 8px;
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
box-shadow: var(--shadow);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.document-card img {
|
|
||||||
|
.document-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 8px 15px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-card .pdf-thumbnail {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 150px;
|
height: 180px;
|
||||||
object-fit: cover;
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-card .pdf-icon {
|
||||||
|
font-size: 60px;
|
||||||
|
color: #e74c3c;
|
||||||
|
}
|
||||||
|
|
||||||
.document-card .info {
|
.document-card .info {
|
||||||
padding: 10px;
|
padding: 15px;
|
||||||
|
background-color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.document-card h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checkbox-container {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.merge-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.status {
|
.status {
|
||||||
margin-bottom: 10px;
|
margin-bottom: 15px;
|
||||||
padding: 10px;
|
padding: 15px;
|
||||||
border-radius: 4px;
|
border-radius: var(--radius);
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.status.scanning {
|
.status.scanning {
|
||||||
background-color: #e6f7ff;
|
background-color: #e6f7ff;
|
||||||
border: 1px solid #91d5ff;
|
border: 1px solid #91d5ff;
|
||||||
|
color: #0050b3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status.scanning::before {
|
||||||
|
background-color: #1890ff;
|
||||||
|
animation: pulse 1.5s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.status.idle {
|
.status.idle {
|
||||||
background-color: #f6ffed;
|
background-color: #f6ffed;
|
||||||
border: 1px solid #b7eb8f;
|
border: 1px solid #b7eb8f;
|
||||||
|
color: #389e0d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status.idle::before {
|
||||||
|
background-color: #52c41a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
z-index: 1000;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox.open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-content {
|
||||||
|
width: 90%;
|
||||||
|
height: 90%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-content iframe {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-lightbox {
|
||||||
|
position: absolute;
|
||||||
|
top: -40px;
|
||||||
|
right: 0;
|
||||||
|
color: white;
|
||||||
|
font-size: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
100% { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.document-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<h1>Document Scanner</h1>
|
<div id="status" class="status idle">Scanner ist bereit</div>
|
||||||
|
|
||||||
<div id="status" class="status idle">Scanner is idle</div>
|
|
||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button id="startBtn">Start Scanning</button>
|
<button id="startBtn">Start Scanning</button>
|
||||||
@ -82,17 +257,33 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Documents</h2>
|
<h2>Documents</h2>
|
||||||
|
<div class="merge-controls">
|
||||||
|
<button id="mergeSelectedBtn" disabled>Merge Selected</button>
|
||||||
|
<button id="mergeAllBtn" disabled>Merge All</button>
|
||||||
|
</div>
|
||||||
<div id="documents" class="document-grid">
|
<div id="documents" class="document-grid">
|
||||||
<p>No documents scanned yet.</p>
|
<p>No documents scanned yet.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="pdfLightbox" class="lightbox">
|
||||||
|
<div class="lightbox-content">
|
||||||
|
<span class="close-lightbox">×</span>
|
||||||
|
<iframe id="pdfViewer"></iframe>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API_URL = `/api`;
|
const API_URL = `/api`;
|
||||||
const startBtn = document.getElementById('startBtn');
|
const startBtn = document.getElementById('startBtn');
|
||||||
const abortBtn = document.getElementById('abortBtn');
|
const abortBtn = document.getElementById('abortBtn');
|
||||||
|
const mergeSelectedBtn = document.getElementById('mergeSelectedBtn');
|
||||||
|
const mergeAllBtn = document.getElementById('mergeAllBtn');
|
||||||
const statusEl = document.getElementById('status');
|
const statusEl = document.getElementById('status');
|
||||||
const documentsEl = document.getElementById('documents');
|
const documentsEl = document.getElementById('documents');
|
||||||
|
const pdfLightbox = document.getElementById('pdfLightbox');
|
||||||
|
const pdfViewer = document.getElementById('pdfViewer');
|
||||||
|
const closeLightbox = document.querySelector('.close-lightbox');
|
||||||
|
|
||||||
function setScannerState(scanning) {
|
function setScannerState(scanning) {
|
||||||
startBtn.disabled = scanning;
|
startBtn.disabled = scanning;
|
||||||
@ -134,12 +325,98 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function mergeDocuments(filenames) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_URL}/documents/merge`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ filenames })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to merge documents');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.mergedFile) {
|
||||||
|
openPdfViewer(result.mergedFile);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error merging documents:', error);
|
||||||
|
alert('Failed to merge documents.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeSelected() {
|
||||||
|
const selectedCheckboxes = document.querySelectorAll('.document-checkbox:checked');
|
||||||
|
if (selectedCheckboxes.length < 2) {
|
||||||
|
alert('Please select at least 2 documents to merge.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filenames = Array.from(selectedCheckboxes).map(checkbox =>
|
||||||
|
checkbox.closest('.document-card').getAttribute('data-filename')
|
||||||
|
);
|
||||||
|
|
||||||
|
mergeDocuments(filenames);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeAll() {
|
||||||
|
const allDocumentCards = document.querySelectorAll('.document-card');
|
||||||
|
if (allDocumentCards.length < 2) {
|
||||||
|
alert('Need at least 2 documents to merge.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filenames = Array.from(allDocumentCards).map(card =>
|
||||||
|
card.getAttribute('data-filename')
|
||||||
|
);
|
||||||
|
|
||||||
|
mergeDocuments(filenames);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMergeButtons() {
|
||||||
|
const documentCount = document.querySelectorAll('.document-card').length;
|
||||||
|
mergeAllBtn.disabled = documentCount < 2;
|
||||||
|
|
||||||
|
const selectedCount = document.querySelectorAll('.document-checkbox:checked').length;
|
||||||
|
mergeSelectedBtn.disabled = selectedCount < 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPdfViewer(pdfUrl) {
|
||||||
|
pdfViewer.src = pdfUrl;
|
||||||
|
pdfLightbox.classList.add('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePdfViewer() {
|
||||||
|
pdfLightbox.classList.remove('open');
|
||||||
|
pdfViewer.src = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentClick(event) {
|
||||||
|
const checkbox = event.target.closest('.document-checkbox');
|
||||||
|
if (checkbox) {
|
||||||
|
updateMergeButtons();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const card = event.target.closest('.document-card');
|
||||||
|
if (card) {
|
||||||
|
const pdfUrl = card.getAttribute('data-url');
|
||||||
|
openPdfViewer(pdfUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pollDocuments() {
|
function pollDocuments() {
|
||||||
fetch(`${API_URL}/documents`)
|
fetch(`${API_URL}/documents`)
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(documents => {
|
.then(documents => {
|
||||||
if (documents.length === 0) {
|
if (documents.length === 0) {
|
||||||
documentsEl.innerHTML = '<p>No documents scanned yet.</p>';
|
documentsEl.innerHTML = '<p>No documents scanned yet.</p>';
|
||||||
|
mergeAllBtn.disabled = true;
|
||||||
|
mergeSelectedBtn.disabled = true;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
pollDocuments();
|
pollDocuments();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@ -147,15 +424,37 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingCards = Array.from(documentsEl.querySelectorAll('.document-card')) || [];
|
||||||
|
const existingFilenames = existingCards.map(card =>
|
||||||
|
card.getAttribute('data-filename')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existingFilenames.length !== documents.length ||
|
||||||
|
!documents.every(doc => existingFilenames.includes(doc.filename))) {
|
||||||
|
documentsEl.innerHTML = '';
|
||||||
|
} else {
|
||||||
|
setTimeout(() => {
|
||||||
|
pollDocuments();
|
||||||
|
}, 2000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
documentsEl.innerHTML = documents.map(doc => `
|
documentsEl.innerHTML = documents.map(doc => `
|
||||||
<div class="document-card">
|
<div class="document-card" data-filename="${doc.filename}" data-url="${doc.url}">
|
||||||
<img src="${doc.url}" alt="${doc.filename}">
|
<div class="checkbox-container">
|
||||||
|
<input type="checkbox" class="document-checkbox">
|
||||||
|
</div>
|
||||||
|
<div class="pdf-thumbnail">
|
||||||
|
<div class="pdf-icon">PDF</div>
|
||||||
|
</div>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<h3>${doc.filename}</h3>
|
<h3>${doc.filename}</h3>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
|
|
||||||
|
updateMergeButtons();
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
pollDocuments();
|
pollDocuments();
|
||||||
}, 2000);
|
}, 2000);
|
||||||
@ -180,6 +479,10 @@
|
|||||||
|
|
||||||
startBtn.addEventListener('click', startScanning);
|
startBtn.addEventListener('click', startScanning);
|
||||||
abortBtn.addEventListener('click', abortScanning);
|
abortBtn.addEventListener('click', abortScanning);
|
||||||
|
mergeSelectedBtn.addEventListener('click', mergeSelected);
|
||||||
|
mergeAllBtn.addEventListener('click', mergeAll);
|
||||||
|
documentsEl.addEventListener('click', handleDocumentClick);
|
||||||
|
closeLightbox.addEventListener('click', closePdfViewer);
|
||||||
|
|
||||||
pollStatus();
|
pollStatus();
|
||||||
pollDocuments();
|
pollDocuments();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user