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:
Warren
2026-07-24 18:47:15 +08:00
parent d1e92b32fb
commit 55bca92691
155 changed files with 25024 additions and 916 deletions
+76
View File
@@ -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>
+67
View File
@@ -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
+83
View File
@@ -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
+48
View File
@@ -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
+1
View File
@@ -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

+499
View File
@@ -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>
+588
View File
@@ -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>
+559
View File
@@ -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>
+12
View File
@@ -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')
+39
View File
@@ -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
+138
View File
@@ -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,
}
})
+93
View File
@@ -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,
}
})
+7
View File
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
}
+55
View File
@@ -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
}
+30
View File
@@ -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
}
+62
View File
@@ -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
}>
}
+36
View File
@@ -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
}
+385
View File
@@ -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>
+202
View File
@@ -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>
+109
View File
@@ -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>
+97
View File
@@ -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>
+62
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module "*.vue" {
import type { DefineComponent } from "vue";
const component: DefineComponent<{}, {}, any>;
export default component;
}