V1.0: Class 1 fax — real-world 4-page send to external number confirmed
Core features: - Class 1 T.30 protocol: full send/receive implementation - HDLC: DLE-stuffing, FCS strip, USR5637 bit-reversal handling - T.4 MH encoder/decoder (1728px A4 standard) - Document pipeline: PDF (Ghostscript), PNG, TIFF input - Width clamping: US Letter 1734px → 1728px fax standard - Cover page: CJK rasterization (TW/CN/JP/EN), TIFF + HTML output - OCR verification: Tesseract 5 with eng+chi_tra, CJK space-tolerant - API server (axum): health, send, jobs, cover, retry, cancel - Background worker: auto-poll queue, speed fallback, retry policy - Modem detection, pool management Real-world test results (2026-07-23): - V90 → 25153038: 4 pages, V.17 12000 bps, 2:33 ✅ - USR5637 → 25153038: 4 pages, V.17 12000 bps, 2:26 ✅ - Both faxes confirmed received on remote machine Tested: loopback (100% pixel match), multi-page, all input formats, cover pages, OCR verify, API endpoints, worker processing. 13 unit tests pass, 0 new clippy warnings.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<nav class="bg-white shadow-sm border-b border-gray-200">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between h-16">
|
||||
<div class="flex">
|
||||
<div class="flex-shrink-0 flex items-center">
|
||||
<h1 class="text-2xl font-bold text-primary-600">Telfax</h1>
|
||||
</div>
|
||||
<div class="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<router-link
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
:to="item.path"
|
||||
class="inline-flex items-center px-1 pt-1 text-sm font-medium border-b-2"
|
||||
:class="[
|
||||
$route.path === item.path
|
||||
? 'border-primary-500 text-gray-900'
|
||||
: 'border-transparent text-gray-500 hover:border-gray-300 hover:text-gray-700',
|
||||
]"
|
||||
>
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="statusColor"
|
||||
></div>
|
||||
<span class="text-sm text-gray-600">{{ statusText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useFaxStore } from './stores/fax'
|
||||
|
||||
const store = useFaxStore()
|
||||
|
||||
const navItems = [
|
||||
{ name: 'Dashboard', path: '/' },
|
||||
{ name: 'Fax History', path: '/history' },
|
||||
{ name: 'Address Book', path: '/address-book' },
|
||||
{ name: 'Modems', path: '/modems' },
|
||||
{ name: 'Settings', path: '/settings' },
|
||||
]
|
||||
|
||||
const statusColor = computed(() => {
|
||||
if (!store.status) return 'bg-gray-400'
|
||||
if (store.status.jobs_failed > 0) return 'bg-red-500'
|
||||
if (store.status.jobs_pending > 0) return 'bg-yellow-500'
|
||||
return 'bg-green-500'
|
||||
})
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (!store.status) return 'Disconnected'
|
||||
if (store.status.jobs_pending > 0) return 'Processing'
|
||||
return 'Ready'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await store.fetchStatus()
|
||||
setInterval(() => store.fetchStatus(), 30000)
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,67 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
||||
const API_TOKEN = import.meta.env.VITE_API_TOKEN || ''
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${API_BASE_URL}/api/v1`,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
if (API_TOKEN) {
|
||||
config.headers.Authorization = `Bearer ${API_TOKEN}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
import type { Contact, Group, CreateContactRequest, UpdateContactRequest, CreateGroupRequest, UpdateGroupRequest } from '../types/addressBook'
|
||||
|
||||
export const addressBookApi = {
|
||||
getContacts: (params?: { query?: string; category?: string }) =>
|
||||
api.get<{ contacts: Contact[]; total: number }>('/contacts', { params }),
|
||||
|
||||
getContact: (id: string) =>
|
||||
api.get<Contact>(`/contacts/${id}`),
|
||||
|
||||
createContact: (data: CreateContactRequest) =>
|
||||
api.post<Contact>('/contacts', data),
|
||||
|
||||
updateContact: (id: string, data: UpdateContactRequest) =>
|
||||
api.put<Contact>(`/contacts/${id}`, data),
|
||||
|
||||
deleteContact: (id: string) =>
|
||||
api.delete(`/contacts/${id}`),
|
||||
|
||||
getGroups: () =>
|
||||
api.get<{ groups: Group[]; total: number }>('/groups'),
|
||||
|
||||
getGroup: (id: string) =>
|
||||
api.get<Group>(`/groups/${id}`),
|
||||
|
||||
createGroup: (data: CreateGroupRequest) =>
|
||||
api.post<Group>('/groups', data),
|
||||
|
||||
updateGroup: (id: string, data: UpdateGroupRequest) =>
|
||||
api.put<Group>(`/groups/${id}`, data),
|
||||
|
||||
deleteGroup: (id: string) =>
|
||||
api.delete(`/groups/${id}`),
|
||||
|
||||
getContactsInGroup: (groupId: string) =>
|
||||
api.get<{ contacts: Contact[]; total: number }>(`/groups/${groupId}/contacts`),
|
||||
|
||||
addContactToGroup: (contactId: string, groupId: string) =>
|
||||
api.post(`/contacts/${contactId}/groups/${groupId}`),
|
||||
|
||||
removeContactFromGroup: (contactId: string, groupId: string) =>
|
||||
api.delete(`/contacts/${contactId}/groups/${groupId}`),
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,83 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
||||
const API_TOKEN = import.meta.env.VITE_API_TOKEN || ''
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${API_BASE_URL}/api/v1`,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
if (API_TOKEN) {
|
||||
config.headers.Authorization = `Bearer ${API_TOKEN}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
console.error('API Error:', error.response?.data || error.message)
|
||||
return Promise.reject(error)
|
||||
}
|
||||
)
|
||||
|
||||
export interface FaxJob {
|
||||
id: string
|
||||
to_number: string
|
||||
status: 'pending' | 'sending' | 'sent' | 'failed' | 'cancelled'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
pages?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ModemStatus {
|
||||
name: string
|
||||
device: string
|
||||
class: number
|
||||
status: 'idle' | 'busy' | 'error' | 'disabled'
|
||||
enabled: boolean
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface ServerStatus {
|
||||
uptime: number
|
||||
jobs_total: number
|
||||
jobs_pending: number
|
||||
jobs_completed: number
|
||||
jobs_failed: number
|
||||
modems: ModemStatus[]
|
||||
}
|
||||
|
||||
export interface SendFaxRequest {
|
||||
to_number: string
|
||||
to_name?: string
|
||||
from_name?: string
|
||||
subject?: string
|
||||
message?: string
|
||||
pdf_path?: string
|
||||
}
|
||||
|
||||
export const faxApi = {
|
||||
getStatus: () => api.get<ServerStatus>('/status'),
|
||||
|
||||
getModems: () => api.get<ModemStatus[]>('/modems'),
|
||||
|
||||
getJobs: () => api.get<FaxJob[]>('/jobs'),
|
||||
|
||||
getJob: (id: string) => api.get<FaxJob>(`/jobs/${id}`),
|
||||
|
||||
sendFax: (data: SendFaxRequest) => api.post<FaxJob>('/fax/send', data),
|
||||
|
||||
cancelJob: (id: string) => api.post(`/jobs/${id}/cancel`),
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1,48 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
||||
const API_TOKEN = import.meta.env.VITE_API_TOKEN || ''
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${API_BASE_URL}/api/v1`,
|
||||
timeout: 60000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
if (API_TOKEN) {
|
||||
config.headers.Authorization = `Bearer ${API_TOKEN}`
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
)
|
||||
|
||||
import type { PageEdit, PreviewResponse } from '../types/editor'
|
||||
|
||||
export const editorApi = {
|
||||
getPreview: (documentPath: string) =>
|
||||
api.get<PreviewResponse>('/preview', { params: { path: documentPath } }),
|
||||
|
||||
applyEdits: (documentPath: string, edits: PageEdit[]) =>
|
||||
api.post('/preview/edit', { document_path: documentPath, edits }),
|
||||
|
||||
generateCoverPreview: (to: string, from: string, subject: string, notes?: string) =>
|
||||
api.post<PreviewResponse>('/preview/cover', { to, from, subject, notes }),
|
||||
|
||||
compareDocuments: (originalPath: string, editedPath: string) =>
|
||||
api.get<{ html: string }>('/preview/compare', {
|
||||
params: { original: originalPath, edited: editedPath }
|
||||
}),
|
||||
|
||||
exportAsPdf: (documentPath: string, outputPath: string) =>
|
||||
api.post('/preview/export/pdf', { input: documentPath, output: outputPath }),
|
||||
|
||||
exportAsTiff: (documentPath: string, outputPath: string) =>
|
||||
api.post('/preview/export/tiff', { input: documentPath, output: outputPath }),
|
||||
}
|
||||
|
||||
export default api
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 496 B |
@@ -0,0 +1,499 @@
|
||||
<template>
|
||||
<div class="archive-preview">
|
||||
<div class="archive-header">
|
||||
<div class="archive-info">
|
||||
<h3>📦 Archive: {{ archiveName }}</h3>
|
||||
<div class="archive-stats">
|
||||
<span class="stat">
|
||||
<span class="stat-label">Format:</span>
|
||||
<span class="stat-value">{{ archiveInfo?.format.toUpperCase() }}</span>
|
||||
</span>
|
||||
<span class="stat">
|
||||
<span class="stat-label">Files:</span>
|
||||
<span class="stat-value">{{ archiveInfo?.file_count }}</span>
|
||||
</span>
|
||||
<span class="stat">
|
||||
<span class="stat-label">Size:</span>
|
||||
<span class="stat-value">{{ formatSize(archiveInfo?.total_size || 0) }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="archive-actions">
|
||||
<button @click="extractAll" class="btn-primary">
|
||||
Extract All
|
||||
</button>
|
||||
<button @click="refreshList" class="btn-secondary">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-filter">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search files..."
|
||||
class="search-input"
|
||||
/>
|
||||
<div class="filter-options">
|
||||
<label>
|
||||
<input type="checkbox" v-model="showFiles" />
|
||||
Files
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="showDirs" />
|
||||
Directories
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading archive contents...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error">
|
||||
<p>{{ error }}</p>
|
||||
<button @click="loadArchive" class="btn-secondary">Retry</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="file-list">
|
||||
<div class="file-list-header">
|
||||
<div class="col-name">Name</div>
|
||||
<div class="col-size">Size</div>
|
||||
<div class="col-compressed">Compressed</div>
|
||||
<div class="col-modified">Modified</div>
|
||||
<div class="col-actions">Actions</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="file in filteredFiles"
|
||||
:key="file.path"
|
||||
class="file-entry"
|
||||
:class="{ 'is-directory': file.is_dir }"
|
||||
>
|
||||
<div class="col-name">
|
||||
<span class="file-icon">{{ file.is_dir ? '📁' : getFileIcon(file.path) }}</span>
|
||||
<span class="file-path">{{ file.path }}</span>
|
||||
</div>
|
||||
|
||||
<div class="col-size">
|
||||
{{ file.is_dir ? '-' : formatSize(file.size) }}
|
||||
</div>
|
||||
|
||||
<div class="col-compressed">
|
||||
{{ file.is_dir ? '-' : formatSize(file.compressed_size) }}
|
||||
</div>
|
||||
|
||||
<div class="col-modified">
|
||||
{{ file.modified ? formatDate(file.modified) : '-' }}
|
||||
</div>
|
||||
|
||||
<div class="col-actions">
|
||||
<button
|
||||
v-if="!file.is_dir"
|
||||
@click="previewFile(file)"
|
||||
class="action-btn"
|
||||
title="Preview"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
<button
|
||||
v-if="!file.is_dir && isFaxFile(file.path)"
|
||||
@click="sendFax(file)"
|
||||
class="action-btn"
|
||||
title="Send as Fax"
|
||||
>
|
||||
📠
|
||||
</button>
|
||||
<button
|
||||
v-if="!file.is_dir"
|
||||
@click="extractFile(file)"
|
||||
class="action-btn"
|
||||
title="Extract"
|
||||
>
|
||||
💾
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredFiles.length === 0" class="no-files">
|
||||
<p>No files found</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import axios from 'axios'
|
||||
import type { ArchiveInfo, ArchiveEntry } from '../types/archive'
|
||||
|
||||
const props = defineProps<{
|
||||
archivePath: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
preview: [file: ArchiveEntry]
|
||||
sendFax: [file: ArchiveEntry]
|
||||
}>()
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'
|
||||
const API_TOKEN = import.meta.env.VITE_API_TOKEN || ''
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${API_BASE_URL}/api/v1`,
|
||||
headers: API_TOKEN ? { Authorization: `Bearer ${API_TOKEN}` } : {}
|
||||
})
|
||||
|
||||
const archiveInfo = ref<ArchiveInfo | null>(null)
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const searchQuery = ref('')
|
||||
const showFiles = ref(true)
|
||||
const showDirs = ref(true)
|
||||
|
||||
const archiveName = computed(() => {
|
||||
return props.archivePath.split('/').pop() || 'Archive'
|
||||
})
|
||||
|
||||
const filteredFiles = computed(() => {
|
||||
if (!archiveInfo.value) return []
|
||||
|
||||
return archiveInfo.value.files.filter(file => {
|
||||
// Filter by type
|
||||
if (!showFiles.value && !file.is_dir) return false
|
||||
if (!showDirs.value && file.is_dir) return false
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.value) {
|
||||
return file.path.toLowerCase().includes(searchQuery.value.toLowerCase())
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
async function loadArchive() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await api.get('/archive/list', {
|
||||
params: { path: props.archivePath }
|
||||
})
|
||||
archiveInfo.value = response.data
|
||||
} catch (e: any) {
|
||||
error.value = e.response?.data?.error || 'Failed to load archive'
|
||||
console.error('Failed to load archive:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function extractFile(file: ArchiveEntry) {
|
||||
try {
|
||||
const outputDir = `/tmp/telfax_extract_${Date.now()}`
|
||||
|
||||
await api.post('/archive/extract', {
|
||||
archive_path: props.archivePath,
|
||||
file_path: file.path,
|
||||
output_dir: outputDir
|
||||
})
|
||||
|
||||
alert(`Extracted to: ${outputDir}/${file.path}`)
|
||||
} catch (e: any) {
|
||||
alert(`Failed to extract: ${e.response?.data?.error || e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function extractAll() {
|
||||
if (!confirm(`Extract all ${archiveInfo.value?.file_count} files?`)) return
|
||||
|
||||
try {
|
||||
const outputDir = `/tmp/telfax_extract_${Date.now()}`
|
||||
|
||||
await api.post('/archive/extract-all', {
|
||||
archive_path: props.archivePath,
|
||||
output_dir: outputDir
|
||||
})
|
||||
|
||||
alert(`Extracted all files to: ${outputDir}`)
|
||||
} catch (e: any) {
|
||||
alert(`Failed to extract: ${e.response?.data?.error || e.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function previewFile(file: ArchiveEntry) {
|
||||
emit('preview', file)
|
||||
}
|
||||
|
||||
function sendFax(file: ArchiveEntry) {
|
||||
emit('sendFax', file)
|
||||
}
|
||||
|
||||
function refreshList() {
|
||||
loadArchive()
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
|
||||
}
|
||||
|
||||
function getFileIcon(path: string): string {
|
||||
const ext = path.split('.').pop()?.toLowerCase()
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
'pdf': '📄',
|
||||
'doc': '📝',
|
||||
'docx': '📝',
|
||||
'xls': '📊',
|
||||
'xlsx': '📊',
|
||||
'ppt': '📽️',
|
||||
'pptx': '📽️',
|
||||
'txt': '📃',
|
||||
'jpg': '🖼️',
|
||||
'jpeg': '🖼️',
|
||||
'png': '🖼️',
|
||||
'gif': '🖼️',
|
||||
'tif': '📠',
|
||||
'tiff': '📠',
|
||||
'zip': '📦',
|
||||
'rar': '📦',
|
||||
'7z': '📦',
|
||||
}
|
||||
|
||||
return icons[ext || ''] || '📄'
|
||||
}
|
||||
|
||||
function isFaxFile(path: string): boolean {
|
||||
const ext = path.toLowerCase()
|
||||
return ext.endsWith('.pdf') ||
|
||||
ext.endsWith('.tif') ||
|
||||
ext.endsWith('.tiff') ||
|
||||
ext.endsWith('.jpg') ||
|
||||
ext.endsWith('.jpeg') ||
|
||||
ext.endsWith('.png')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadArchive()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.archive-preview {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.archive-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 15px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.archive-info h3 {
|
||||
margin: 0 0 10px 0;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.archive-stats {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6b7280;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.archive-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.file-filter {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.filter-options label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 14px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.error,
|
||||
.no-files {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 0 auto 10px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.file-list {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-list-header {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
background: #f9fafb;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.file-entry {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr 1fr 1fr 1fr;
|
||||
gap: 10px;
|
||||
padding: 10px 15px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
font-size: 13px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.file-entry:hover {
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.file-entry.is-directory {
|
||||
background: #fefefe;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.file-path {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.col-size,
|
||||
.col-compressed,
|
||||
.col-modified {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
padding: 4px 8px;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,588 @@
|
||||
<template>
|
||||
<div class="preview-adjust">
|
||||
<div class="adjustment-panel">
|
||||
<h3>Image Adjustments</h3>
|
||||
|
||||
<!-- Presets -->
|
||||
<div class="preset-section">
|
||||
<h4>Quick Presets</h4>
|
||||
<div class="preset-grid">
|
||||
<button
|
||||
v-for="preset in presets"
|
||||
:key="preset.value"
|
||||
@click="applyPreset(preset.value)"
|
||||
class="preset-btn"
|
||||
:class="{ active: currentPreset === preset.value }"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color Mode -->
|
||||
<div class="adjust-section">
|
||||
<h4>Color Mode</h4>
|
||||
<div class="color-options">
|
||||
<button
|
||||
@click="adjustments.grayscale = false"
|
||||
:class="{ active: !adjustments.grayscale }"
|
||||
class="mode-btn"
|
||||
>
|
||||
🎨 Color
|
||||
</button>
|
||||
<button
|
||||
@click="adjustments.grayscale = true"
|
||||
:class="{ active: adjustments.grayscale }"
|
||||
class="mode-btn"
|
||||
>
|
||||
⬛ Black & White
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Brightness -->
|
||||
<div class="adjust-section">
|
||||
<h4>
|
||||
Brightness
|
||||
<span class="value">{{ adjustments.brightness }}</span>
|
||||
</h4>
|
||||
<input
|
||||
v-model.number="adjustments.brightness"
|
||||
type="range"
|
||||
min="-100"
|
||||
max="100"
|
||||
step="1"
|
||||
class="slider"
|
||||
/>
|
||||
<div class="slider-labels">
|
||||
<span>-100</span>
|
||||
<span>0</span>
|
||||
<span>+100</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Contrast -->
|
||||
<div class="adjust-section">
|
||||
<h4>
|
||||
Contrast
|
||||
<span class="value">{{ Math.round(adjustments.contrast * 100) }}%</span>
|
||||
</h4>
|
||||
<input
|
||||
v-model.number="adjustments.contrast"
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
step="1"
|
||||
class="slider"
|
||||
/>
|
||||
<div class="slider-labels">
|
||||
<span>0%</span>
|
||||
<span>100%</span>
|
||||
<span>200%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Color Temperature -->
|
||||
<div class="adjust-section">
|
||||
<h4>
|
||||
Color Temperature
|
||||
<span class="value">
|
||||
{{ adjustments.temperature > 0 ? 'Warm' : adjustments.temperature < 0 ? 'Cool' : 'Neutral' }}
|
||||
({{ adjustments.temperature }})
|
||||
</span>
|
||||
</h4>
|
||||
<input
|
||||
v-model.number="adjustments.temperature"
|
||||
type="range"
|
||||
min="-100"
|
||||
max="100"
|
||||
step="1"
|
||||
class="slider temperature-slider"
|
||||
/>
|
||||
<div class="slider-labels">
|
||||
<span>❄️ Cool</span>
|
||||
<span>Neutral</span>
|
||||
<span>🔥 Warm</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saturation -->
|
||||
<div class="adjust-section">
|
||||
<h4>
|
||||
Saturation
|
||||
<span class="value">{{ Math.round(adjustments.saturation * 100) }}%</span>
|
||||
</h4>
|
||||
<input
|
||||
v-model.number="adjustments.saturation"
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
step="1"
|
||||
class="slider"
|
||||
/>
|
||||
<div class="slider-labels">
|
||||
<span>0%</span>
|
||||
<span>100%</span>
|
||||
<span>200%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invert -->
|
||||
<div class="adjust-section">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
v-model="adjustments.invert"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>Invert Colors</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="action-buttons">
|
||||
<button @click="resetAdjustments" class="btn-secondary">
|
||||
Reset
|
||||
</button>
|
||||
<button @click="applyAdjustments" class="btn-primary">
|
||||
Apply Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Annotation Tools -->
|
||||
<div class="annotation-panel">
|
||||
<h3>Annotations</h3>
|
||||
|
||||
<div class="tool-grid">
|
||||
<button
|
||||
v-for="tool in annotationTools"
|
||||
:key="tool.type"
|
||||
@click="selectTool(tool.type)"
|
||||
class="tool-btn"
|
||||
:class="{ active: selectedTool === tool.type }"
|
||||
>
|
||||
<span class="tool-icon">{{ tool.icon }}</span>
|
||||
<span class="tool-label">{{ tool.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="color-picker">
|
||||
<h4>Color</h4>
|
||||
<div class="color-options">
|
||||
<button
|
||||
v-for="color in colors"
|
||||
:key="color.value"
|
||||
@click="selectedColor = color.value"
|
||||
class="color-btn"
|
||||
:style="{ backgroundColor: color.hex }"
|
||||
:class="{ active: selectedColor === color.value }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="opacity-slider">
|
||||
<h4>Opacity: {{ Math.round(annotationOpacity * 100) }}%</h4>
|
||||
<input
|
||||
v-model.number="annotationOpacity"
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Text Input for Text Annotation -->
|
||||
<div v-if="selectedTool === 'text'" class="text-input">
|
||||
<h4>Text</h4>
|
||||
<input
|
||||
v-model="annotationText"
|
||||
type="text"
|
||||
placeholder="Enter text..."
|
||||
class="text-field"
|
||||
/>
|
||||
<div class="font-size">
|
||||
<label>Font Size: {{ fontSize }}px</label>
|
||||
<input
|
||||
v-model.number="fontSize"
|
||||
type="range"
|
||||
min="12"
|
||||
max="72"
|
||||
step="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive } from 'vue'
|
||||
import type { ImageAdjustments, AdjustmentPreset, AnnotationType, Color } from '../types/editor'
|
||||
|
||||
const emit = defineEmits<{
|
||||
adjust: [adjustments: ImageAdjustments]
|
||||
annotate: [annotation: any]
|
||||
}>()
|
||||
|
||||
const adjustments = reactive<ImageAdjustments>({
|
||||
brightness: 0,
|
||||
contrast: 1.0,
|
||||
temperature: 0,
|
||||
saturation: 1.0,
|
||||
grayscale: false,
|
||||
invert: false,
|
||||
})
|
||||
|
||||
const currentPreset = ref<AdjustmentPreset>('original')
|
||||
|
||||
const presets = [
|
||||
{ label: 'Original', value: 'original' as AdjustmentPreset },
|
||||
{ label: 'Brighten', value: 'brighten' as AdjustmentPreset },
|
||||
{ label: 'Darken', value: 'darken' as AdjustmentPreset },
|
||||
{ label: 'High Contrast', value: 'high_contrast' as AdjustmentPreset },
|
||||
{ label: 'Low Contrast', value: 'low_contrast' as AdjustmentPreset },
|
||||
{ label: 'Warm', value: 'warm' as AdjustmentPreset },
|
||||
{ label: 'Cool', value: 'cool' as AdjustmentPreset },
|
||||
{ label: 'Document', value: 'document' as AdjustmentPreset },
|
||||
{ label: 'Photo', value: 'photo' as AdjustmentPreset },
|
||||
]
|
||||
|
||||
const annotationTools = [
|
||||
{ type: 'text' as AnnotationType, icon: '📝', label: 'Text' },
|
||||
{ type: 'rectangle' as AnnotationType, icon: '⬜', label: 'Rectangle' },
|
||||
{ type: 'circle' as AnnotationType, icon: '⭕', label: 'Circle' },
|
||||
{ type: 'line' as AnnotationType, icon: '📏', label: 'Line' },
|
||||
{ type: 'arrow' as AnnotationType, icon: '➡️', label: 'Arrow' },
|
||||
{ type: 'highlight' as AnnotationType, icon: '🖍️', label: 'Highlight' },
|
||||
{ type: 'stamp' as AnnotationType, icon: '🔖', label: 'Stamp' },
|
||||
{ type: 'freehand' as AnnotationType, icon: '✏️', label: 'Freehand' },
|
||||
]
|
||||
|
||||
const colors = [
|
||||
{ value: 'red' as Color, hex: '#ef4444' },
|
||||
{ value: 'green' as Color, hex: '#10b981' },
|
||||
{ value: 'blue' as Color, hex: '#3b82f6' },
|
||||
{ value: 'yellow' as Color, hex: '#f59e0b' },
|
||||
{ value: 'orange' as Color, hex: '#f97316' },
|
||||
{ value: 'purple' as Color, hex: '#8b5cf6' },
|
||||
{ value: 'black' as Color, hex: '#000000' },
|
||||
{ value: 'white' as Color, hex: '#ffffff' },
|
||||
]
|
||||
|
||||
const selectedTool = ref<AnnotationType>('text')
|
||||
const selectedColor = ref<Color>('red')
|
||||
const annotationOpacity = ref(1.0)
|
||||
const annotationText = ref('')
|
||||
const fontSize = ref(16)
|
||||
|
||||
function applyPreset(preset: AdjustmentPreset) {
|
||||
currentPreset.value = preset
|
||||
|
||||
switch (preset) {
|
||||
case 'original':
|
||||
resetAdjustments()
|
||||
break
|
||||
case 'brighten':
|
||||
adjustments.brightness = 30
|
||||
adjustments.contrast = 1.1
|
||||
break
|
||||
case 'darken':
|
||||
adjustments.brightness = -30
|
||||
adjustments.contrast = 0.9
|
||||
break
|
||||
case 'high_contrast':
|
||||
adjustments.contrast = 1.5
|
||||
break
|
||||
case 'low_contrast':
|
||||
adjustments.contrast = 0.7
|
||||
break
|
||||
case 'warm':
|
||||
adjustments.temperature = 40
|
||||
break
|
||||
case 'cool':
|
||||
adjustments.temperature = -40
|
||||
break
|
||||
case 'document':
|
||||
adjustments.brightness = 10
|
||||
adjustments.contrast = 1.2
|
||||
adjustments.grayscale = true
|
||||
break
|
||||
case 'photo':
|
||||
adjustments.brightness = 5
|
||||
adjustments.contrast = 1.1
|
||||
adjustments.saturation = 1.2
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function resetAdjustments() {
|
||||
adjustments.brightness = 0
|
||||
adjustments.contrast = 1.0
|
||||
adjustments.temperature = 0
|
||||
adjustments.saturation = 1.0
|
||||
adjustments.grayscale = false
|
||||
adjustments.invert = false
|
||||
currentPreset.value = 'original'
|
||||
}
|
||||
|
||||
function applyAdjustments() {
|
||||
emit('adjust', { ...adjustments })
|
||||
}
|
||||
|
||||
function selectTool(tool: AnnotationType) {
|
||||
selectedTool.value = tool
|
||||
}
|
||||
|
||||
function createAnnotation(x: number, y: number, width?: number, height?: number) {
|
||||
const annotation = {
|
||||
id: Date.now().toString(),
|
||||
annotation_type: selectedTool.value,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
text: selectedTool.value === 'text' ? annotationText.value : undefined,
|
||||
color: selectedColor.value,
|
||||
font_size: selectedTool.value === 'text' ? fontSize.value : undefined,
|
||||
opacity: annotationOpacity.value,
|
||||
}
|
||||
|
||||
emit('annotate', annotation)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-adjust {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
padding: 20px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.adjustment-panel,
|
||||
.annotation-panel {
|
||||
flex: 1;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
color: #111827;
|
||||
border-bottom: 2px solid #3b82f6;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 15px 0 10px 0;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.preset-section,
|
||||
.adjust-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.preset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-btn {
|
||||
padding: 8px;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.preset-btn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.preset-btn.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.color-options {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mode-btn {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.mode-btn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.mode-btn.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.slider {
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.temperature-slider {
|
||||
accent-color: #f97316;
|
||||
}
|
||||
|
||||
.slider-labels {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-label input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary {
|
||||
flex: 1;
|
||||
padding: 10px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #d1d5db;
|
||||
}
|
||||
|
||||
.tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px 5px;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.tool-btn.active {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.tool-icon {
|
||||
font-size: 20px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tool-label {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.color-picker {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.color-btn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.color-btn:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.color-btn.active {
|
||||
border-color: #111827;
|
||||
box-shadow: 0 0 0 2px white, 0 0 0 4px #3b82f6;
|
||||
}
|
||||
|
||||
.opacity-slider input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-input input[type="text"] {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.text-input .font-size {
|
||||
margin-top: 10px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,559 @@
|
||||
<template>
|
||||
<div class="preview-editor">
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button
|
||||
@click="zoomIn"
|
||||
class="tool-btn"
|
||||
title="Zoom In"
|
||||
>
|
||||
🔍+
|
||||
</button>
|
||||
<button
|
||||
@click="zoomOut"
|
||||
class="tool-btn"
|
||||
title="Zoom Out"
|
||||
>
|
||||
🔍-
|
||||
</button>
|
||||
<button
|
||||
@click="fitToWidth"
|
||||
class="tool-btn"
|
||||
title="Fit to Width"
|
||||
>
|
||||
↔️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-center">
|
||||
<button
|
||||
v-if="currentPage > 0"
|
||||
@click="currentPage--"
|
||||
class="tool-btn"
|
||||
>
|
||||
◀ Previous
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Page {{ currentPage + 1 }} / {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
v-if="currentPage < totalPages - 1"
|
||||
@click="currentPage++"
|
||||
class="tool-btn"
|
||||
>
|
||||
Next ▶
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<button
|
||||
@click="toggleEditMode"
|
||||
:class="{ active: editMode }"
|
||||
class="tool-btn"
|
||||
>
|
||||
✏️ Edit
|
||||
</button>
|
||||
<button
|
||||
@click="print"
|
||||
class="tool-btn"
|
||||
>
|
||||
🖨️ Print
|
||||
</button>
|
||||
<button
|
||||
@click="download"
|
||||
class="tool-btn"
|
||||
>
|
||||
💾 Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-container">
|
||||
<div
|
||||
v-if="editMode"
|
||||
class="edit-tools"
|
||||
>
|
||||
<h3>Edit Tools</h3>
|
||||
|
||||
<div class="tool-section">
|
||||
<h4>Rotate</h4>
|
||||
<button @click="rotate(90)" class="edit-btn">90°</button>
|
||||
<button @click="rotate(180)" class="edit-btn">180°</button>
|
||||
<button @click="rotate(270)" class="edit-btn">270°</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-section">
|
||||
<h4>Flip</h4>
|
||||
<button @click="flip('horizontal')" class="edit-btn">↔️ Horizontal</button>
|
||||
<button @click="flip('vertical')" class="edit-btn">↕️ Vertical</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-section">
|
||||
<h4>Brightness</h4>
|
||||
<input
|
||||
v-model="brightness"
|
||||
type="range"
|
||||
min="-100"
|
||||
max="100"
|
||||
@change="adjustBrightness"
|
||||
/>
|
||||
<span>{{ brightness }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tool-section">
|
||||
<h4>Contrast</h4>
|
||||
<input
|
||||
v-model="contrast"
|
||||
type="range"
|
||||
min="0"
|
||||
max="200"
|
||||
step="10"
|
||||
@change="adjustContrast"
|
||||
/>
|
||||
<span>{{ contrast }}%</span>
|
||||
</div>
|
||||
|
||||
<div class="tool-section">
|
||||
<button @click="grayscale" class="edit-btn">Grayscale</button>
|
||||
<button @click="invert" class="edit-btn">Invert</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-section">
|
||||
<h4>Annotations</h4>
|
||||
<button @click="addText" class="edit-btn">Add Text</button>
|
||||
<button @click="addRectangle" class="edit-btn">Add Rectangle</button>
|
||||
<button @click="addLine" class="edit-btn">Add Line</button>
|
||||
</div>
|
||||
|
||||
<div class="tool-section">
|
||||
<button @click="resetPage" class="edit-btn danger">Reset Page</button>
|
||||
<button @click="applyEdits" class="edit-btn primary">Apply Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="canvas-container"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<div
|
||||
:style="{ transform: `scale(${scale})` }"
|
||||
class="canvas-wrapper"
|
||||
>
|
||||
<img
|
||||
v-if="currentDataUrl"
|
||||
:src="currentDataUrl"
|
||||
:alt="`Page ${currentPage + 1}`"
|
||||
class="preview-image"
|
||||
/>
|
||||
<canvas
|
||||
ref="annotationCanvas"
|
||||
class="annotation-layer"
|
||||
@mousedown="startAnnotation"
|
||||
@mousemove="updateAnnotation"
|
||||
@mouseup="finishAnnotation"
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-overlay">
|
||||
<div class="spinner"></div>
|
||||
<p>Loading preview...</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { editorApi } from '../api/editor'
|
||||
import type { PreviewResponse, Annotation } from '../types/editor'
|
||||
|
||||
const props = defineProps<{
|
||||
documentPath?: string
|
||||
coverData?: {
|
||||
to: string
|
||||
from: string
|
||||
subject: string
|
||||
notes?: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const loading = ref(false)
|
||||
const preview = ref<PreviewResponse | null>(null)
|
||||
const currentPage = ref(0)
|
||||
const scale = ref(1)
|
||||
const editMode = ref(false)
|
||||
const brightness = ref(0)
|
||||
const contrast = ref(100)
|
||||
|
||||
const canvasContainer = ref<HTMLElement | null>(null)
|
||||
const annotationCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const totalPages = computed(() => preview.value?.total_pages || 0)
|
||||
const currentDataUrl = computed(() => {
|
||||
if (!preview.value || currentPage.value >= preview.value.pages.length) {
|
||||
return null
|
||||
}
|
||||
return preview.value.pages[currentPage.value].data_url
|
||||
})
|
||||
|
||||
const pageEdits = ref<Map<number, any[]>>(new Map())
|
||||
|
||||
async function loadPreview() {
|
||||
loading.value = true
|
||||
try {
|
||||
if (props.documentPath) {
|
||||
const response = await editorApi.getPreview(props.documentPath)
|
||||
preview.value = response.data
|
||||
} else if (props.coverData) {
|
||||
const response = await editorApi.generateCoverPreview(
|
||||
props.coverData.to,
|
||||
props.coverData.from,
|
||||
props.coverData.subject,
|
||||
props.coverData.notes
|
||||
)
|
||||
preview.value = response.data
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load preview:', error)
|
||||
alert('Failed to load preview')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function zoomIn() {
|
||||
scale.value = Math.min(scale.value + 0.25, 3)
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
scale.value = Math.max(scale.value - 0.25, 0.25)
|
||||
}
|
||||
|
||||
function fitToWidth() {
|
||||
if (!canvasContainer.value || !preview.value) return
|
||||
const containerWidth = canvasContainer.value.clientWidth
|
||||
const pageWidth = preview.value.pages[currentPage.value]?.width || 1728
|
||||
scale.value = containerWidth / pageWidth
|
||||
}
|
||||
|
||||
function handleWheel(event: WheelEvent) {
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
event.preventDefault()
|
||||
if (event.deltaY < 0) {
|
||||
zoomIn()
|
||||
} else {
|
||||
zoomOut()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEditMode() {
|
||||
editMode.value = !editMode.value
|
||||
}
|
||||
|
||||
function rotate(degrees: 90 | 180 | 270) {
|
||||
addEdit(currentPage.value, { rotation: degrees })
|
||||
}
|
||||
|
||||
function flip(direction: 'horizontal' | 'vertical') {
|
||||
addEdit(currentPage.value, { flip: direction })
|
||||
}
|
||||
|
||||
function adjustBrightness() {
|
||||
addEdit(currentPage.value, { brightness: brightness.value })
|
||||
}
|
||||
|
||||
function adjustContrast() {
|
||||
addEdit(currentPage.value, { contrast: contrast.value })
|
||||
}
|
||||
|
||||
function grayscale() {
|
||||
addEdit(currentPage.value, { grayscale: true })
|
||||
}
|
||||
|
||||
function invert() {
|
||||
addEdit(currentPage.value, { invert: true })
|
||||
}
|
||||
|
||||
function addEdit(page: number, edit: any) {
|
||||
if (!pageEdits.value.has(page)) {
|
||||
pageEdits.value.set(page, [])
|
||||
}
|
||||
pageEdits.value.get(page)!.push(edit)
|
||||
}
|
||||
|
||||
function resetPage() {
|
||||
pageEdits.value.delete(currentPage.value)
|
||||
loadPreview()
|
||||
}
|
||||
|
||||
async function applyEdits() {
|
||||
if (!props.documentPath) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const edits = Array.from(pageEdits.value.entries()).map(([page, edits]) => ({
|
||||
page_number: page,
|
||||
...edits.reduce((acc, edit) => ({ ...acc, ...edit }), {})
|
||||
}))
|
||||
|
||||
await editorApi.applyEdits(props.documentPath, edits)
|
||||
pageEdits.value.clear()
|
||||
await loadPreview()
|
||||
} catch (error) {
|
||||
console.error('Failed to apply edits:', error)
|
||||
alert('Failed to apply edits')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function addText() {
|
||||
const text = prompt('Enter text:')
|
||||
if (text) {
|
||||
const annotation: Annotation = {
|
||||
id: Date.now().toString(),
|
||||
annotation_type: 'text',
|
||||
x: 100,
|
||||
y: 100,
|
||||
text,
|
||||
color: [255, 0, 0]
|
||||
}
|
||||
addEdit(currentPage.value, { annotations: [annotation] })
|
||||
}
|
||||
}
|
||||
|
||||
function addRectangle() {
|
||||
const annotation: Annotation = {
|
||||
id: Date.now().toString(),
|
||||
annotation_type: 'rectangle',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 100,
|
||||
color: [255, 0, 0]
|
||||
}
|
||||
addEdit(currentPage.value, { annotations: [annotation] })
|
||||
}
|
||||
|
||||
function addLine() {
|
||||
const annotation: Annotation = {
|
||||
id: Date.now().toString(),
|
||||
annotation_type: 'line',
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 300,
|
||||
height: 0,
|
||||
color: [255, 0, 0]
|
||||
}
|
||||
addEdit(currentPage.value, { annotations: [annotation] })
|
||||
}
|
||||
|
||||
function startAnnotation(event: MouseEvent) {
|
||||
// Handle annotation drawing
|
||||
}
|
||||
|
||||
function updateAnnotation(event: MouseEvent) {
|
||||
// Handle annotation drawing
|
||||
}
|
||||
|
||||
function finishAnnotation(event: MouseEvent) {
|
||||
// Handle annotation drawing
|
||||
}
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
|
||||
function download() {
|
||||
if (!currentDataUrl.value) return
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = currentDataUrl.value
|
||||
link.download = `page-${currentPage.value + 1}.png`
|
||||
link.click()
|
||||
}
|
||||
|
||||
watch(() => props.documentPath, loadPreview, { immediate: true })
|
||||
watch(() => props.coverData, loadPreview, { deep: true })
|
||||
|
||||
onMounted(() => {
|
||||
loadPreview()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.preview-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 20px;
|
||||
background: white;
|
||||
border-bottom: 1px solid #ddd;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.toolbar-left,
|
||||
.toolbar-center,
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.tool-btn {
|
||||
padding: 8px 16px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tool-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.tool-btn.active {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-weight: 600;
|
||||
margin: 0 10px;
|
||||
}
|
||||
|
||||
.editor-container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.edit-tools {
|
||||
width: 280px;
|
||||
background: white;
|
||||
border-right: 1px solid #ddd;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.edit-tools h3 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.tool-section {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.tool-section h4 {
|
||||
margin-bottom: 10px;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
margin: 5px 0;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.edit-btn.primary {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
border-color: #10b981;
|
||||
}
|
||||
|
||||
.edit-btn.danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.canvas-wrapper {
|
||||
position: relative;
|
||||
transform-origin: top center;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.preview-image {
|
||||
display: block;
|
||||
max-width: none;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
.annotation-layer {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.loading-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: white;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 3px solid rgba(255,255,255,0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media print {
|
||||
.toolbar,
|
||||
.edit-tools {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
import './style.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,39 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
import FaxHistory from '../views/FaxHistory.vue'
|
||||
import ModemStatus from '../views/ModemStatus.vue'
|
||||
import Settings from '../views/Settings.vue'
|
||||
import AddressBook from '../views/AddressBook.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'dashboard',
|
||||
component: Dashboard,
|
||||
},
|
||||
{
|
||||
path: '/history',
|
||||
name: 'history',
|
||||
component: FaxHistory,
|
||||
},
|
||||
{
|
||||
path: '/modems',
|
||||
name: 'modems',
|
||||
component: ModemStatus,
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
component: Settings,
|
||||
},
|
||||
{
|
||||
path: '/address-book',
|
||||
name: 'address-book',
|
||||
component: AddressBook,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,138 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { addressBookApi } from '../api/addressBook'
|
||||
import type { Contact, Group, CreateContactRequest, UpdateContactRequest, CreateGroupRequest } from '../types/addressBook'
|
||||
|
||||
export const useAddressBookStore = defineStore('addressBook', () => {
|
||||
const contacts = ref<Contact[]>([])
|
||||
const groups = ref<Group[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchContacts(query?: string, category?: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await addressBookApi.getContacts({ query, category })
|
||||
contacts.value = response.data.contacts
|
||||
} catch (e) {
|
||||
error.value = 'Failed to fetch contacts'
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createContact(data: CreateContactRequest) {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await addressBookApi.createContact(data)
|
||||
await fetchContacts()
|
||||
return response.data
|
||||
} catch (e) {
|
||||
error.value = 'Failed to create contact'
|
||||
console.error(e)
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateContact(id: string, data: UpdateContactRequest) {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await addressBookApi.updateContact(id, data)
|
||||
await fetchContacts()
|
||||
return response.data
|
||||
} catch (e) {
|
||||
error.value = 'Failed to update contact'
|
||||
console.error(e)
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContact(id: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
await addressBookApi.deleteContact(id)
|
||||
await fetchContacts()
|
||||
} catch (e) {
|
||||
error.value = 'Failed to delete contact'
|
||||
console.error(e)
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchGroups() {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await addressBookApi.getGroups()
|
||||
groups.value = response.data.groups
|
||||
} catch (e) {
|
||||
error.value = 'Failed to fetch groups'
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createGroup(data: CreateGroupRequest) {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await addressBookApi.createGroup(data)
|
||||
await fetchGroups()
|
||||
return response.data
|
||||
} catch (e) {
|
||||
error.value = 'Failed to create group'
|
||||
console.error(e)
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroup(id: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
await addressBookApi.deleteGroup(id)
|
||||
await fetchGroups()
|
||||
} catch (e) {
|
||||
error.value = 'Failed to delete group'
|
||||
console.error(e)
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addContactToGroup(contactId: string, groupId: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
await addressBookApi.addContactToGroup(contactId, groupId)
|
||||
} catch (e) {
|
||||
error.value = 'Failed to add contact to group'
|
||||
console.error(e)
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
contacts,
|
||||
groups,
|
||||
loading,
|
||||
error,
|
||||
fetchContacts,
|
||||
createContact,
|
||||
updateContact,
|
||||
deleteContact,
|
||||
fetchGroups,
|
||||
createGroup,
|
||||
deleteGroup,
|
||||
addContactToGroup,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { faxApi } from '../api/client'
|
||||
import type { ServerStatus, FaxJob, ModemStatus } from '../types'
|
||||
|
||||
export const useFaxStore = defineStore('fax', () => {
|
||||
const status = ref<ServerStatus | null>(null)
|
||||
const jobs = ref<FaxJob[]>([])
|
||||
const modems = ref<ModemStatus[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await faxApi.getStatus()
|
||||
status.value = response.data
|
||||
} catch (e) {
|
||||
error.value = 'Failed to fetch status'
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJobs() {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await faxApi.getJobs()
|
||||
jobs.value = response.data
|
||||
} catch (e) {
|
||||
error.value = 'Failed to fetch jobs'
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchModems() {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await faxApi.getModems()
|
||||
modems.value = response.data
|
||||
} catch (e) {
|
||||
error.value = 'Failed to fetch modems'
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function sendFax(to_number: string, options: any = {}) {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await faxApi.sendFax({ to_number, ...options })
|
||||
await fetchJobs()
|
||||
return response.data
|
||||
} catch (e) {
|
||||
error.value = 'Failed to send fax'
|
||||
console.error(e)
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelJob(id: string) {
|
||||
try {
|
||||
loading.value = true
|
||||
await faxApi.cancelJob(id)
|
||||
await fetchJobs()
|
||||
} catch (e) {
|
||||
error.value = 'Failed to cancel job'
|
||||
console.error(e)
|
||||
throw e
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
jobs,
|
||||
modems,
|
||||
loading,
|
||||
error,
|
||||
fetchStatus,
|
||||
fetchJobs,
|
||||
fetchModems,
|
||||
sendFax,
|
||||
cancelJob,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
export interface Contact {
|
||||
id: string
|
||||
name: string
|
||||
fax_number: string
|
||||
company?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
category?: string
|
||||
notes?: string
|
||||
is_favorite: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface Group {
|
||||
id: string
|
||||
name: string
|
||||
description?: string
|
||||
color?: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateContactRequest {
|
||||
name: string
|
||||
fax_number: string
|
||||
company?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
category?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface UpdateContactRequest {
|
||||
name?: string
|
||||
fax_number?: string
|
||||
company?: string
|
||||
email?: string
|
||||
phone?: string
|
||||
category?: string
|
||||
notes?: string
|
||||
is_favorite?: boolean
|
||||
}
|
||||
|
||||
export interface CreateGroupRequest {
|
||||
name: string
|
||||
description?: string
|
||||
color?: string
|
||||
}
|
||||
|
||||
export interface UpdateGroupRequest {
|
||||
name?: string
|
||||
description?: string
|
||||
color?: string
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
export interface ArchiveInfo {
|
||||
path: string
|
||||
format: ArchiveFormat
|
||||
files: ArchiveEntry[]
|
||||
total_size: number
|
||||
file_count: number
|
||||
}
|
||||
|
||||
export type ArchiveFormat =
|
||||
| 'zip'
|
||||
| 'tar'
|
||||
| 'tar_gz'
|
||||
| 'tar_bz2'
|
||||
| 'tar_xz'
|
||||
| 'rar'
|
||||
| 'seven_z'
|
||||
|
||||
export interface ArchiveEntry {
|
||||
path: string
|
||||
size: number
|
||||
is_dir: boolean
|
||||
compressed_size: number
|
||||
modified?: string
|
||||
}
|
||||
|
||||
export interface ExtractOptions {
|
||||
archive_path: string
|
||||
file_path?: string // If specified, extract only this file
|
||||
output_dir: string
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
export interface Annotation {
|
||||
id: string
|
||||
annotation_type: 'text' | 'rectangle' | 'circle' | 'line' | 'arrow' | 'highlight' | 'stamp' | 'freehand'
|
||||
x: number
|
||||
y: number
|
||||
width?: number
|
||||
height?: number
|
||||
text?: string
|
||||
color: Color
|
||||
font_size?: number
|
||||
opacity: number
|
||||
}
|
||||
|
||||
export type Color =
|
||||
| 'red'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'yellow'
|
||||
| 'orange'
|
||||
| 'purple'
|
||||
| 'black'
|
||||
| 'white'
|
||||
| { r: number; g: number; b: number }
|
||||
|
||||
export interface ImageAdjustments {
|
||||
brightness: number // -100 to 100
|
||||
contrast: number // 0.0 to 2.0
|
||||
temperature: number // -100 to 100
|
||||
saturation: number // 0.0 to 2.0
|
||||
grayscale: boolean
|
||||
invert: boolean
|
||||
}
|
||||
|
||||
export type AdjustmentPreset =
|
||||
| 'original'
|
||||
| 'brighten'
|
||||
| 'darken'
|
||||
| 'high_contrast'
|
||||
| 'low_contrast'
|
||||
| 'warm'
|
||||
| 'cool'
|
||||
| 'document'
|
||||
| 'photo'
|
||||
|
||||
export interface PageEdit {
|
||||
page_number: number
|
||||
crop?: { x: number; y: number; width: number; height: number }
|
||||
rotation?: 90 | 180 | 270
|
||||
flip?: 'horizontal' | 'vertical'
|
||||
adjustments?: ImageAdjustments
|
||||
annotations?: Annotation[]
|
||||
}
|
||||
|
||||
export interface PreviewResponse {
|
||||
total_pages: number
|
||||
pages: Array<{
|
||||
width: number
|
||||
height: number
|
||||
dpi: number
|
||||
data_url: string
|
||||
}>
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
export interface FaxJob {
|
||||
id: string
|
||||
to_number: string
|
||||
status: 'pending' | 'sending' | 'sent' | 'failed' | 'cancelled'
|
||||
created_at: string
|
||||
updated_at: string
|
||||
pages?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ModemStatus {
|
||||
name: string
|
||||
device: string
|
||||
class: number
|
||||
status: 'idle' | 'busy' | 'error' | 'disabled'
|
||||
enabled: boolean
|
||||
priority: number
|
||||
}
|
||||
|
||||
export interface ServerStatus {
|
||||
uptime: number
|
||||
jobs_total: number
|
||||
jobs_pending: number
|
||||
jobs_completed: number
|
||||
jobs_failed: number
|
||||
modems: ModemStatus[]
|
||||
}
|
||||
|
||||
export interface SendFaxRequest {
|
||||
to_number: string
|
||||
to_name?: string
|
||||
from_name?: string
|
||||
subject?: string
|
||||
message?: string
|
||||
pdf_path?: string
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Address Book</h2>
|
||||
<div class="flex space-x-3">
|
||||
<button
|
||||
@click="showAddContactModal = true"
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Add Contact
|
||||
</button>
|
||||
<button
|
||||
@click="showGroupsModal = true"
|
||||
class="px-4 py-2 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Groups
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="mb-4">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search contacts..."
|
||||
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
@input="handleSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.contacts.length === 0" class="text-center py-8">
|
||||
<p class="text-gray-600">No contacts found</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Fax Number</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Company</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Category</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="contact in store.contacts" :key="contact.id" class="hover:bg-gray-50">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center">
|
||||
<div
|
||||
v-if="contact.is_favorite"
|
||||
class="w-2 h-2 bg-yellow-400 rounded-full mr-2"
|
||||
title="Favorite"
|
||||
></div>
|
||||
<div class="text-sm font-medium text-gray-900">{{ contact.name }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ contact.fax_number }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ contact.company || '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
v-if="contact.category"
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-primary-100 text-primary-800"
|
||||
>
|
||||
{{ contact.category }}
|
||||
</span>
|
||||
<span v-else class="text-gray-400">-</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button
|
||||
@click="editContact(contact)"
|
||||
class="text-primary-600 hover:text-primary-900 mr-3"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
@click="sendFax(contact)"
|
||||
class="text-green-600 hover:text-green-900 mr-3"
|
||||
>
|
||||
Send Fax
|
||||
</button>
|
||||
<button
|
||||
@click="deleteContact(contact.id)"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showAddContactModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-md">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||
{{ editingContact ? 'Edit Contact' : 'Add Contact' }}
|
||||
</h3>
|
||||
<form @submit.prevent="saveContact" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Name *</label>
|
||||
<input
|
||||
v-model="contactForm.name"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Fax Number *</label>
|
||||
<input
|
||||
v-model="contactForm.fax_number"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Company</label>
|
||||
<input
|
||||
v-model="contactForm.company"
|
||||
type="text"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Email</label>
|
||||
<input
|
||||
v-model="contactForm.email"
|
||||
type="email"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Phone</label>
|
||||
<input
|
||||
v-model="contactForm.phone"
|
||||
type="text"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Category</label>
|
||||
<input
|
||||
v-model="contactForm.category"
|
||||
type="text"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Notes</label>
|
||||
<textarea
|
||||
v-model="contactForm.notes"
|
||||
rows="3"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
v-model="contactForm.is_favorite"
|
||||
type="checkbox"
|
||||
class="h-4 w-4 text-primary-600 focus:ring-primary-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label class="ml-2 block text-sm text-gray-700">Mark as favorite</label>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeModal"
|
||||
class="px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="store.loading"
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{{ editingContact ? 'Update' : 'Create' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showGroupsModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-2xl">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Groups</h3>
|
||||
<button
|
||||
@click="showGroupsModal = false"
|
||||
class="text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="group in store.groups"
|
||||
:key="group.id"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{{ group.name }}</p>
|
||||
<p class="text-sm text-gray-500">{{ group.description || 'No description' }}</p>
|
||||
</div>
|
||||
<button
|
||||
@click="deleteGroup(group.id)"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="store.groups.length === 0" class="text-center py-4 text-gray-500">
|
||||
No groups created
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-gray-200">
|
||||
<h4 class="text-sm font-medium text-gray-900 mb-2">Create New Group</h4>
|
||||
<form @submit.prevent="createGroup" class="flex space-x-2">
|
||||
<input
|
||||
v-model="newGroupName"
|
||||
type="text"
|
||||
placeholder="Group name"
|
||||
required
|
||||
class="flex-1 px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAddressBookStore } from '../stores/addressBook'
|
||||
import type { Contact, CreateContactRequest, UpdateContactRequest } from '../types/addressBook'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useAddressBookStore()
|
||||
|
||||
const searchQuery = ref('')
|
||||
const showAddContactModal = ref(false)
|
||||
const showGroupsModal = ref(false)
|
||||
const editingContact = ref<Contact | null>(null)
|
||||
const newGroupName = ref('')
|
||||
|
||||
const contactForm = ref<CreateContactRequest & { is_favorite?: boolean }>({
|
||||
name: '',
|
||||
fax_number: '',
|
||||
company: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
category: '',
|
||||
notes: '',
|
||||
is_favorite: false,
|
||||
})
|
||||
|
||||
let searchTimeout: ReturnType<typeof setTimeout>
|
||||
|
||||
function handleSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
store.fetchContacts(searchQuery.value || undefined)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function editContact(contact: Contact) {
|
||||
editingContact.value = contact
|
||||
contactForm.value = {
|
||||
name: contact.name,
|
||||
fax_number: contact.fax_number,
|
||||
company: contact.company || '',
|
||||
email: contact.email || '',
|
||||
phone: contact.phone || '',
|
||||
category: contact.category || '',
|
||||
notes: contact.notes || '',
|
||||
}
|
||||
showAddContactModal.value = true
|
||||
}
|
||||
|
||||
async function saveContact() {
|
||||
try {
|
||||
if (editingContact.value) {
|
||||
const updateData: UpdateContactRequest = {}
|
||||
if (contactForm.value.name !== editingContact.value.name) updateData.name = contactForm.value.name
|
||||
if (contactForm.value.fax_number !== editingContact.value.fax_number) updateData.fax_number = contactForm.value.fax_number
|
||||
if (contactForm.value.company !== editingContact.value.company) updateData.company = contactForm.value.company
|
||||
if (contactForm.value.email !== editingContact.value.email) updateData.email = contactForm.value.email
|
||||
if (contactForm.value.phone !== editingContact.value.phone) updateData.phone = contactForm.value.phone
|
||||
if (contactForm.value.category !== editingContact.value.category) updateData.category = contactForm.value.category
|
||||
if (contactForm.value.notes !== editingContact.value.notes) updateData.notes = contactForm.value.notes
|
||||
|
||||
await store.updateContact(editingContact.value.id, updateData)
|
||||
} else {
|
||||
await store.createContact(contactForm.value)
|
||||
}
|
||||
closeModal()
|
||||
} catch (error) {
|
||||
alert('Failed to save contact')
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showAddContactModal.value = false
|
||||
editingContact.value = null
|
||||
contactForm.value = {
|
||||
name: '',
|
||||
fax_number: '',
|
||||
company: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
category: '',
|
||||
notes: '',
|
||||
is_favorite: false,
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteContact(id: string) {
|
||||
if (confirm('Are you sure you want to delete this contact?')) {
|
||||
try {
|
||||
await store.deleteContact(id)
|
||||
} catch (error) {
|
||||
alert('Failed to delete contact')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendFax(contact: Contact) {
|
||||
router.push({
|
||||
path: '/',
|
||||
query: {
|
||||
to_number: contact.fax_number,
|
||||
to_name: contact.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function createGroup() {
|
||||
if (!newGroupName.value.trim()) return
|
||||
|
||||
try {
|
||||
await store.createGroup({
|
||||
name: newGroupName.value.trim(),
|
||||
})
|
||||
newGroupName.value = ''
|
||||
} catch (error) {
|
||||
alert('Failed to create group')
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteGroup(id: string) {
|
||||
if (confirm('Are you sure you want to delete this group?')) {
|
||||
try {
|
||||
await store.deleteGroup(id)
|
||||
} catch (error) {
|
||||
alert('Failed to delete group')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchContacts()
|
||||
store.fetchGroups()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,202 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Dashboard</h2>
|
||||
<button
|
||||
@click="refreshAll"
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.error" class="bg-red-50 border border-red-200 rounded-lg p-4">
|
||||
<p class="text-red-800">{{ store.error }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-600">Total Jobs</p>
|
||||
<p class="text-3xl font-bold text-gray-900">
|
||||
{{ store.status?.jobs_total || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-primary-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-primary-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-600">Pending</p>
|
||||
<p class="text-3xl font-bold text-yellow-600">
|
||||
{{ store.status?.jobs_pending || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-yellow-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-600">Completed</p>
|
||||
<p class="text-3xl font-bold text-green-600">
|
||||
{{ store.status?.jobs_completed || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<div class="flex items-center">
|
||||
<div class="flex-1">
|
||||
<p class="text-sm font-medium text-gray-600">Failed</p>
|
||||
<p class="text-3xl font-bold text-red-600">
|
||||
{{ store.status?.jobs_failed || 0 }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Send Fax</h3>
|
||||
<form @submit.prevent="sendFax" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">To Number</label>
|
||||
<input
|
||||
v-model="sendForm.to_number"
|
||||
type="text"
|
||||
required
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="e.g., 0212345678"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Subject</label>
|
||||
<input
|
||||
v-model="sendForm.subject"
|
||||
type="text"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Optional subject"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">Message</label>
|
||||
<textarea
|
||||
v-model="sendForm.message"
|
||||
rows="3"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Optional cover page message"
|
||||
></textarea>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="store.loading"
|
||||
class="w-full px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Send Fax
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Active Modems</h3>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-for="modem in store.modems"
|
||||
:key="modem.device"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900">{{ modem.name }}</p>
|
||||
<p class="text-sm text-gray-500">Class {{ modem.class }} • {{ modem.device }}</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="{
|
||||
'bg-green-500': modem.status === 'idle',
|
||||
'bg-yellow-500': modem.status === 'busy',
|
||||
'bg-red-500': modem.status === 'error',
|
||||
'bg-gray-400': !modem.enabled,
|
||||
}"
|
||||
></div>
|
||||
<span class="text-sm text-gray-600 capitalize">{{ modem.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="store.modems.length === 0" class="text-center py-4 text-gray-500">
|
||||
No modems configured
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useFaxStore } from '../stores/fax'
|
||||
|
||||
const store = useFaxStore()
|
||||
|
||||
const sendForm = ref({
|
||||
to_number: '',
|
||||
subject: '',
|
||||
message: '',
|
||||
})
|
||||
|
||||
async function sendFax() {
|
||||
try {
|
||||
await store.sendFax(sendForm.value.to_number, {
|
||||
subject: sendForm.value.subject,
|
||||
message: sendForm.value.message,
|
||||
})
|
||||
sendForm.value = { to_number: '', subject: '', message: '' }
|
||||
alert('Fax sent successfully!')
|
||||
} catch (error) {
|
||||
alert('Failed to send fax')
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
await Promise.all([
|
||||
store.fetchStatus(),
|
||||
store.fetchModems(),
|
||||
store.fetchJobs(),
|
||||
])
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshAll()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Fax History</h2>
|
||||
<button
|
||||
@click="store.fetchJobs"
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.jobs.length === 0" class="bg-white rounded-lg shadow p-8 text-center">
|
||||
<p class="text-gray-600">No fax jobs found</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<table class="min-w-full divide-y divide-gray-200">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Job ID
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
To Number
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Status
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Created
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
<tr v-for="job in store.jobs" :key="job.id">
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-900">
|
||||
{{ job.id.substring(0, 8) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{{ job.to_number }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="{
|
||||
'bg-yellow-100 text-yellow-800': job.status === 'pending',
|
||||
'bg-blue-100 text-blue-800': job.status === 'sending',
|
||||
'bg-green-100 text-green-800': job.status === 'sent',
|
||||
'bg-red-100 text-red-800': job.status === 'failed',
|
||||
'bg-gray-100 text-gray-800': job.status === 'cancelled',
|
||||
}"
|
||||
>
|
||||
{{ job.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{{ formatDate(job.created_at) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<button
|
||||
v-if="job.status === 'pending'"
|
||||
@click="cancelJob(job.id)"
|
||||
class="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useFaxStore } from '../stores/fax'
|
||||
import dayjs from 'dayjs'
|
||||
|
||||
const store = useFaxStore()
|
||||
|
||||
function formatDate(date: string) {
|
||||
return dayjs(date).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
async function cancelJob(id: string) {
|
||||
if (confirm('Are you sure you want to cancel this job?')) {
|
||||
try {
|
||||
await store.cancelJob(id)
|
||||
await store.fetchJobs()
|
||||
} catch (error) {
|
||||
alert('Failed to cancel job')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchJobs()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Modem Status</h2>
|
||||
<button
|
||||
@click="store.fetchModems"
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="text-center py-8">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary-600"></div>
|
||||
<p class="mt-2 text-gray-600">Loading...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.modems.length === 0" class="bg-white rounded-lg shadow p-8 text-center">
|
||||
<p class="text-gray-600">No modems configured</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div
|
||||
v-for="modem in store.modems"
|
||||
:key="modem.device"
|
||||
class="bg-white rounded-lg shadow p-6"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ modem.name }}</h3>
|
||||
<p class="text-sm text-gray-500">{{ modem.device }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center space-x-2 px-3 py-1 rounded-full"
|
||||
:class="{
|
||||
'bg-green-100': modem.status === 'idle' && modem.enabled,
|
||||
'bg-yellow-100': modem.status === 'busy',
|
||||
'bg-red-100': modem.status === 'error',
|
||||
'bg-gray-100': !modem.enabled,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="{
|
||||
'bg-green-500': modem.status === 'idle' && modem.enabled,
|
||||
'bg-yellow-500': modem.status === 'busy',
|
||||
'bg-red-500': modem.status === 'error',
|
||||
'bg-gray-400': !modem.enabled,
|
||||
}"
|
||||
></div>
|
||||
<span
|
||||
class="text-sm font-medium capitalize"
|
||||
:class="{
|
||||
'text-green-800': modem.status === 'idle' && modem.enabled,
|
||||
'text-yellow-800': modem.status === 'busy',
|
||||
'text-red-800': modem.status === 'error',
|
||||
'text-gray-800': !modem.enabled,
|
||||
}"
|
||||
>
|
||||
{{ modem.enabled ? modem.status : 'Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Class</span>
|
||||
<span class="font-medium text-gray-900">Class {{ modem.class }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Priority</span>
|
||||
<span class="font-medium text-gray-900">{{ modem.priority }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-gray-600">Enabled</span>
|
||||
<span class="font-medium text-gray-900">{{ modem.enabled ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="modem.status === 'error'" class="mt-4 p-3 bg-red-50 rounded-lg">
|
||||
<p class="text-sm text-red-800">Modem error detected</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useFaxStore } from '../stores/fax'
|
||||
|
||||
const store = useFaxStore()
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchModems()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Settings</h2>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">API Configuration</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">API URL</label>
|
||||
<input
|
||||
v-model="settings.apiUrl"
|
||||
type="text"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="http://localhost:3000"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700">API Token</label>
|
||||
<input
|
||||
v-model="settings.apiToken"
|
||||
type="password"
|
||||
class="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary-500 focus:border-primary-500"
|
||||
placeholder="Enter your API token"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@click="saveSettings"
|
||||
class="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
Save Settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">About</h3>
|
||||
<div class="space-y-2 text-sm text-gray-600">
|
||||
<p><strong>Telfax Enterprise Fax Server</strong></p>
|
||||
<p>Version: 1.0.0</p>
|
||||
<p>Build: Tauri + Vue 3</p>
|
||||
<p>License: MIT</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const settings = ref({
|
||||
apiUrl: localStorage.getItem('api_url') || 'http://localhost:3000',
|
||||
apiToken: localStorage.getItem('api_token') || '',
|
||||
})
|
||||
|
||||
function saveSettings() {
|
||||
localStorage.setItem('api_url', settings.value.apiUrl)
|
||||
localStorage.setItem('api_token', settings.value.apiToken)
|
||||
alert('Settings saved! Please reload the app to apply changes.')
|
||||
}
|
||||
</script>
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
Reference in New Issue
Block a user