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
+2
View File
@@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:3000
VITE_API_TOKEN=your-dev-token-here
+24
View File
@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"tauri-apps.tauri-vscode",
"rust-lang.rust-analyzer"
]
}
+207
View File
@@ -0,0 +1,207 @@
# Telfax Web UI
Modern Web UI for Telfax Enterprise Fax Server built with **Tauri + Vue 3 + TypeScript**.
## Features
- **Dashboard**: Real-time status overview, send faxes directly
- **Fax History**: View and manage fax jobs
- **Modem Status**: Monitor modem health and status
- **Settings**: Configure API connection and authentication
## Tech Stack
- **Frontend**: Vue 3 + TypeScript + Vite
- **Styling**: Tailwind CSS
- **State Management**: Pinia
- **Routing**: Vue Router 4
- **HTTP Client**: Axios
- **Desktop App**: Tauri 2.0
## Development
### Prerequisites
- Node.js 18+
- Rust (for Tauri)
- Telfax backend running on `http://localhost:3000`
### Install Dependencies
```bash
cd web-ui
npm install
```
### Run Development Server
```bash
# Web only (for development)
npm run dev
# Desktop app (with Tauri)
npm run tauri dev
```
The app will be available at `http://localhost:1420`
### Build for Production
```bash
# Build web app
npm run build
# Build desktop app (all platforms)
npm run tauri build
```
## Configuration
### Environment Variables
Create `.env` file in `web-ui/` directory:
```env
VITE_API_URL=http://localhost:3000
VITE_API_TOKEN=your-api-token-here
```
### Production Configuration
1. Set API URL in `.env`:
```env
VITE_API_URL=https://your-server.com
VITE_API_TOKEN=prod-admin-token-change-me-abc123xyz
```
2. Build and deploy:
```bash
npm run build
```
3. Deploy `dist/` directory to your web server
## Architecture
```
web-ui/
├── src/
│ ├── api/ # API client and types
│ │ └── client.ts # Axios client with interceptors
│ ├── components/ # Reusable Vue components
│ ├── router/ # Vue Router configuration
│ │ └── index.ts
│ ├── stores/ # Pinia stores
│ │ └── fax.ts # Fax state management
│ ├── types/ # TypeScript interfaces
│ │ └── index.ts
│ ├── utils/ # Utility functions
│ ├── views/ # Page components
│ │ ├── Dashboard.vue
│ │ ├── FaxHistory.vue
│ │ ├── ModemStatus.vue
│ │ └── Settings.vue
│ ├── App.vue # Root component
│ ├── main.ts # Application entry point
│ └── style.css # Global styles (Tailwind)
├── src-tauri/ # Tauri backend (Rust)
│ └── tauri.conf.json
├── .env # Environment variables
├── package.json
├── tailwind.config.js
├── postcss.config.js
└── vite.config.ts
```
## API Endpoints
The Web UI connects to the Telfax backend REST API:
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/v1/status` | GET | Server status |
| `/api/v1/modems` | GET | List modems |
| `/api/v1/jobs` | GET | List fax jobs |
| `/api/v1/jobs/:id` | GET | Get job details |
| `/api/v1/fax/send` | POST | Send fax |
| `/api/v1/jobs/:id/cancel` | POST | Cancel job |
All endpoints require `Authorization: Bearer <token>` header.
## Screenshots
### Dashboard
- Real-time statistics (total, pending, completed, failed jobs)
- Quick send fax form
- Active modems overview
### Fax History
- Table view of all fax jobs
- Status badges
- Cancel pending jobs
### Modem Status
- Modem health monitoring
- Class and priority information
- Status indicators (idle, busy, error, disabled)
### Settings
- API URL configuration
- Authentication token management
- App information
## Desktop App Features
When running as a Tauri desktop app:
- Native window controls
- System tray integration (optional)
- Auto-update support (optional)
- Offline mode (view cached data)
- Native notifications
## Troubleshooting
### Development Issues
1. **API Connection Failed**
- Check if Telfax backend is running
- Verify `.env` configuration
- Check CORS settings on backend
2. **Build Errors**
- Run `npm install` to ensure dependencies
- Check Node.js version (18+)
- Clear cache: `rm -rf node_modules package-lock.json && npm install`
3. **Tauri Build Fails**
- Ensure Rust is installed
- Check platform-specific dependencies
- Run `npm run tauri dev` first to test
### Production Issues
1. **Authentication Errors**
- Verify API token in Settings
- Check token permissions (admin/write/read)
2. **Modem Not Detected**
- Check backend logs
- Verify modem device paths in backend config
## License
MIT License - See LICENSE file for details.
## Contributing
1. Fork the repository
2. Create feature branch: `git checkout -b feature/amazing-feature`
3. Commit changes: `git commit -m 'Add amazing feature'`
4. Push to branch: `git push origin feature/amazing-feature`
5. Open Pull Request
## Support
- GitHub Issues: https://github.com/yourorg/telfax/issues
- Documentation: `/docs`
+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + Vue + Typescript App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+3137
View File
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
{
"name": "web-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"tauri": "tauri"
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2",
"@vueuse/core": "^14.3.0",
"axios": "^1.18.1",
"dayjs": "^1.11.21",
"pinia": "^3.0.4",
"vue": "^3.5.13",
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.3.2",
"@tauri-apps/cli": "^2",
"@types/node": "^26.1.1",
"@vitejs/plugin-vue": "^5.2.1",
"autoprefixer": "^10.5.2",
"postcss": "^8.5.16",
"tailwindcss": "^4.3.2",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"vue-tsc": "^2.1.10"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
}
+6
View File
@@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

+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="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+7
View File
@@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "web-ui"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "web_ui_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -0,0 +1,10 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"opener:default"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+14
View File
@@ -0,0 +1,14 @@
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
+6
View File
@@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
web_ui_lib::run()
}
+45
View File
@@ -0,0 +1,45 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Telfax",
"version": "1.0.0",
"identifier": "com.accusys.telfax",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "npm run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "Telfax - Enterprise Fax Server",
"width": 1280,
"height": 800,
"minWidth": 1024,
"minHeight": 600,
"resizable": true,
"fullscreen": false
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"category": "Business",
"shortDescription": "Enterprise Fax Server Management UI",
"longDescription": "Telfax is a modern, enterprise-grade fax server with support for Class 1 and Class 2 modems.",
"macOS": {
"minimumSystemVersion": "10.13"
}
}
}
+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;
}
+26
View File
@@ -0,0 +1,26 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
},
},
},
plugins: [],
}
+25
View File
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+32
View File
@@ -0,0 +1,32 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// @ts-expect-error process is a nodejs global
const host = process.env.TAURI_DEV_HOST;
// https://vite.dev/config/
export default defineConfig(async () => ({
plugins: [vue()],
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
//
// 1. prevent Vite from obscuring rust errors
clearScreen: false,
// 2. tauri expects a fixed port, fail if that port is not available
server: {
port: 1420,
strictPort: true,
host: host || false,
hmr: host
? {
protocol: "ws",
host,
port: 1421,
}
: undefined,
watch: {
// 3. tell Vite to ignore watching `src-tauri`
ignored: ["**/src-tauri/**"],
},
},
}));