Initial commit: IP Monitor - Modern Windows Desktop Application
Features: - IP/Country detection from public APIs - M3U8 playlist parsing and channel management - Single URL channel support - Live stream monitoring with HLS.js video player - Ping/TCP health checks - Stream error detection (CONNRESET, 404, timeout) - Windows Toast notifications - Telegram Bot notifications - Email (SMTP) notifications - Auto/Manual notification options - MONITOR_API_GUIDE panel integration - System tray support - Persistent settings (portable mode support) - Modern dark UI with Tailwind CSS Tech Stack: - Electron 28 - React 18 + TypeScript - Vite - Tailwind CSS + Framer Motion - HLS.js for video playback - electron-store for persistent data
This commit is contained in:
commit
adb574e68e
|
|
@ -0,0 +1,34 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
release/
|
||||||
|
*.exe
|
||||||
|
*.dmg
|
||||||
|
*.AppImage
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# Electron Store data
|
||||||
|
config.json
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
# IP Monitor
|
||||||
|
|
||||||
|
Modern Windows masaüstü uygulaması - IP izleme, m3u8 kanal kontrolü ve bildirim sistemi.
|
||||||
|
|
||||||
|
## Özellikler
|
||||||
|
|
||||||
|
### 🌐 IP Bilgileri
|
||||||
|
- Public IP adresinizi otomatik tespit
|
||||||
|
- Ülke, şehir ve ISP bilgilerini görüntüleme
|
||||||
|
- ip-api.com ve ipify.org API entegrasyonu
|
||||||
|
|
||||||
|
### 📺 M3U8 Kanal Yönetimi
|
||||||
|
- M3U8/M3U playlist URL'lerini içe aktarma
|
||||||
|
- Kanal listesi görüntüleme ve gruplandırma
|
||||||
|
- İzlemek istediğiniz kanalları seçme
|
||||||
|
- Kanal logolarını ve metadata'sını gösterme
|
||||||
|
|
||||||
|
### 📊 Canlı İzleme
|
||||||
|
- Seçili kanalların ping ve stream kontrolü
|
||||||
|
- HTTP durum kodları takibi (404, 502, vb.)
|
||||||
|
- Bağlantı hataları tespiti (ERR_CONNRESET, ECONNREFUSED)
|
||||||
|
- Gerçek zamanlı durum güncellemeleri
|
||||||
|
- Gecikme (latency) ölçümü
|
||||||
|
|
||||||
|
### 🔔 Bildirim Sistemi
|
||||||
|
- **Windows Toast**: Yerel Windows bildirimleri
|
||||||
|
- **Telegram Bot**: Telegram üzerinden anlık bildirim
|
||||||
|
- **Email (SMTP)**: E-posta ile hata raporları
|
||||||
|
- Otomatik veya manuel bildirim seçeneği
|
||||||
|
- Özelleştirilebilir bildirim kuralları
|
||||||
|
|
||||||
|
### 🔧 Panel Entegrasyonu
|
||||||
|
- MONITOR_API_GUIDE uyumlu API entegrasyonu
|
||||||
|
- IP listesi çekme (REST/SSE)
|
||||||
|
- Burn vote gönderme
|
||||||
|
- Health check raporlama
|
||||||
|
|
||||||
|
## Kurulum
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Bağımlılıkları yükle
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# Geliştirme modunda çalıştır
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Production build
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Electron uygulaması oluştur
|
||||||
|
npm run build:electron
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gereksinimler
|
||||||
|
|
||||||
|
- Node.js 18+
|
||||||
|
- npm veya yarn
|
||||||
|
- Windows 10/11 (64-bit)
|
||||||
|
|
||||||
|
## Teknoloji Stack
|
||||||
|
|
||||||
|
- **Electron 28** - Desktop framework
|
||||||
|
- **React 18** - UI library
|
||||||
|
- **TypeScript** - Type safety
|
||||||
|
- **Tailwind CSS** - Styling
|
||||||
|
- **Framer Motion** - Animations
|
||||||
|
- **Zustand** - State management
|
||||||
|
- **Axios** - HTTP client
|
||||||
|
- **electron-store** - Settings persistence
|
||||||
|
|
||||||
|
## Konfigürasyon
|
||||||
|
|
||||||
|
### Telegram Bildirimleri
|
||||||
|
1. [@BotFather](https://t.me/BotFather) ile yeni bot oluşturun
|
||||||
|
2. Bot token'ı alın
|
||||||
|
3. Chat ID'nizi [@userinfobot](https://t.me/userinfobot) ile öğrenin
|
||||||
|
4. Ayarlar sayfasından bilgileri girin
|
||||||
|
|
||||||
|
### Email Bildirimleri
|
||||||
|
1. SMTP sunucu bilgilerini girin (Gmail, Outlook, vb.)
|
||||||
|
2. Gmail için App Password kullanmanız gerekebilir
|
||||||
|
3. Test butonuyla bağlantıyı kontrol edin
|
||||||
|
|
||||||
|
### Panel API
|
||||||
|
1. Panel URL'sini girin
|
||||||
|
2. Monitor ID tanımlayın
|
||||||
|
3. Auth token'ı girin (panel'den alınır)
|
||||||
|
|
||||||
|
## Lisans
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/electron-userland/electron-builder/master/packages/app-builder-lib/scheme.json",
|
||||||
|
"appId": "com.ipmonitor.app",
|
||||||
|
"productName": "IP Monitor",
|
||||||
|
"directories": {
|
||||||
|
"output": "release",
|
||||||
|
"buildResources": "assets"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist/**/*",
|
||||||
|
"index.html"
|
||||||
|
],
|
||||||
|
"extraMetadata": {
|
||||||
|
"main": "dist/main/main/index.js"
|
||||||
|
},
|
||||||
|
"asar": true,
|
||||||
|
"win": {
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"target": "portable",
|
||||||
|
"arch": ["x64"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"artifactName": "${productName}-Portable-${version}.${ext}",
|
||||||
|
"signAndEditExecutable": false
|
||||||
|
},
|
||||||
|
"portable": {
|
||||||
|
"artifactName": "${productName}-Portable-${version}.${ext}"
|
||||||
|
},
|
||||||
|
"publish": null
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="tr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob: data:; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https: http:; media-src 'self' blob: data: https: http: *; connect-src 'self' https: http: ws: wss: *">
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
<title>IP Monitor</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/renderer/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,61 @@
|
||||||
|
{
|
||||||
|
"name": "ip-monitor",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Modern IP Monitor Desktop Application",
|
||||||
|
"main": "dist/main/main/index.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "concurrently \"npm run dev:vite\" \"npm run dev:electron\"",
|
||||||
|
"dev:vite": "vite",
|
||||||
|
"dev:electron": "wait-on tcp:5173 && cross-env NODE_ENV=development electron .",
|
||||||
|
"build": "npm run build:main && npm run build:renderer",
|
||||||
|
"build:main": "tsc -p tsconfig.main.json",
|
||||||
|
"build:renderer": "vite build",
|
||||||
|
"build:electron": "npm run build && electron-builder --win",
|
||||||
|
"build:portable": "npm run build && electron-builder --win portable",
|
||||||
|
"pack": "npm run build && electron-builder --dir",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"electron",
|
||||||
|
"ip-monitor",
|
||||||
|
"m3u8",
|
||||||
|
"streaming"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.10.0",
|
||||||
|
"@types/node-telegram-bot-api": "^0.64.13",
|
||||||
|
"@types/nodemailer": "^7.0.4",
|
||||||
|
"@types/react": "^18.2.42",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"concurrently": "^8.2.2",
|
||||||
|
"cross-env": "^10.1.0",
|
||||||
|
"electron": "^28.0.0",
|
||||||
|
"electron-builder": "^24.9.1",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.3.6",
|
||||||
|
"typescript": "^5.3.2",
|
||||||
|
"vite": "^5.0.6",
|
||||||
|
"wait-on": "^7.2.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.6.2",
|
||||||
|
"electron-store": "^8.1.0",
|
||||||
|
"framer-motion": "^10.16.16",
|
||||||
|
"hls.js": "^1.6.15",
|
||||||
|
"lucide-react": "^0.294.0",
|
||||||
|
"m3u8-parser": "^7.1.0",
|
||||||
|
"node-telegram-bot-api": "^0.64.0",
|
||||||
|
"nodemailer": "^6.9.7",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-player": "^3.4.0",
|
||||||
|
"react-router-dom": "^6.21.0",
|
||||||
|
"tcp-ping": "^0.1.1",
|
||||||
|
"zustand": "^4.4.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
import { app, BrowserWindow, ipcMain, Tray, Menu, nativeImage } from 'electron'
|
||||||
|
import * as path from 'path'
|
||||||
|
import {
|
||||||
|
getStore,
|
||||||
|
getSettings,
|
||||||
|
saveSettings,
|
||||||
|
getChannels,
|
||||||
|
saveChannels,
|
||||||
|
getErrorLog,
|
||||||
|
addErrorLog,
|
||||||
|
clearErrorLog,
|
||||||
|
getWindowBounds,
|
||||||
|
saveWindowBounds,
|
||||||
|
defaultSettings,
|
||||||
|
isPortableMode
|
||||||
|
} from './store'
|
||||||
|
import { IpService } from './services/IpService'
|
||||||
|
import { PingService } from './services/PingService'
|
||||||
|
import { StreamChecker } from './services/StreamChecker'
|
||||||
|
import { NotificationService } from './services/NotificationService'
|
||||||
|
import { PanelService } from './services/PanelService'
|
||||||
|
import { M3u8Parser } from './services/M3u8Parser'
|
||||||
|
import { IPC_CHANNELS } from '../shared/types'
|
||||||
|
import type { AppSettings, Channel, ErrorLogEntry } from '../shared/types'
|
||||||
|
|
||||||
|
// App quitting flag
|
||||||
|
let isAppQuitting = false
|
||||||
|
|
||||||
|
// Services
|
||||||
|
let ipService: IpService
|
||||||
|
let pingService: PingService
|
||||||
|
let streamChecker: StreamChecker
|
||||||
|
let notificationService: NotificationService
|
||||||
|
let panelService: PanelService
|
||||||
|
let m3u8Parser: M3u8Parser
|
||||||
|
|
||||||
|
// Window and tray
|
||||||
|
let mainWindow: BrowserWindow | null = null
|
||||||
|
let tray: Tray | null = null
|
||||||
|
|
||||||
|
function createWindow() {
|
||||||
|
const bounds = getWindowBounds()
|
||||||
|
|
||||||
|
mainWindow = new BrowserWindow({
|
||||||
|
width: bounds.width,
|
||||||
|
height: bounds.height,
|
||||||
|
x: bounds.x,
|
||||||
|
y: bounds.y,
|
||||||
|
minWidth: 1200,
|
||||||
|
minHeight: 700,
|
||||||
|
frame: false,
|
||||||
|
backgroundColor: '#0f0f12',
|
||||||
|
webPreferences: {
|
||||||
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
},
|
||||||
|
icon: path.join(__dirname, '../../../assets/icon.png'),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Save window bounds on resize/move
|
||||||
|
mainWindow.on('resize', () => {
|
||||||
|
if (mainWindow && !mainWindow.isMaximized()) {
|
||||||
|
const bounds = mainWindow.getBounds()
|
||||||
|
saveWindowBounds(bounds)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('move', () => {
|
||||||
|
if (mainWindow && !mainWindow.isMaximized()) {
|
||||||
|
const bounds = mainWindow.getBounds()
|
||||||
|
saveWindowBounds(bounds)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load the app
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
mainWindow.loadURL('http://localhost:5173')
|
||||||
|
mainWindow.webContents.openDevTools()
|
||||||
|
} else {
|
||||||
|
mainWindow.loadFile(path.join(__dirname, '../../renderer/index.html'))
|
||||||
|
}
|
||||||
|
|
||||||
|
mainWindow.on('close', (event) => {
|
||||||
|
const settings = getSettings()
|
||||||
|
if (settings.general.minimizeToTray && !isAppQuitting) {
|
||||||
|
event.preventDefault()
|
||||||
|
mainWindow?.hide()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
mainWindow.on('closed', () => {
|
||||||
|
mainWindow = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTray() {
|
||||||
|
// Create a simple tray icon
|
||||||
|
const iconPath = path.join(__dirname, '../../../assets/tray-icon.png')
|
||||||
|
let icon: Electron.NativeImage
|
||||||
|
|
||||||
|
try {
|
||||||
|
icon = nativeImage.createFromPath(iconPath)
|
||||||
|
if (icon.isEmpty()) {
|
||||||
|
icon = nativeImage.createEmpty()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
icon = nativeImage.createEmpty()
|
||||||
|
}
|
||||||
|
|
||||||
|
tray = new Tray(icon)
|
||||||
|
|
||||||
|
const contextMenu = Menu.buildFromTemplate([
|
||||||
|
{
|
||||||
|
label: 'Göster',
|
||||||
|
click: () => {
|
||||||
|
mainWindow?.show()
|
||||||
|
mainWindow?.focus()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'İzlemeyi Başlat',
|
||||||
|
click: () => {
|
||||||
|
streamChecker?.start()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'İzlemeyi Durdur',
|
||||||
|
click: () => {
|
||||||
|
streamChecker?.stop()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: isPortableMode() ? 'Portable Mod ✓' : 'Normal Mod',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
{ type: 'separator' },
|
||||||
|
{
|
||||||
|
label: 'Çıkış',
|
||||||
|
click: () => {
|
||||||
|
isAppQuitting = true
|
||||||
|
app.quit()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
tray.setToolTip('IP Monitor')
|
||||||
|
tray.setContextMenu(contextMenu)
|
||||||
|
|
||||||
|
tray.on('double-click', () => {
|
||||||
|
mainWindow?.show()
|
||||||
|
mainWindow?.focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function initializeServices() {
|
||||||
|
const settings = getSettings()
|
||||||
|
|
||||||
|
ipService = new IpService()
|
||||||
|
pingService = new PingService(settings.monitoring.timeout)
|
||||||
|
m3u8Parser = new M3u8Parser()
|
||||||
|
notificationService = new NotificationService(settings.notifications)
|
||||||
|
panelService = new PanelService(settings.panel)
|
||||||
|
|
||||||
|
streamChecker = new StreamChecker(
|
||||||
|
settings.monitoring,
|
||||||
|
(result) => {
|
||||||
|
// Send result to renderer
|
||||||
|
mainWindow?.webContents.send(IPC_CHANNELS.MONITORING_UPDATE, result)
|
||||||
|
|
||||||
|
// Auto notify on error
|
||||||
|
if (settings.notifications.autoNotify && result.status === 'error') {
|
||||||
|
const channels = getChannels()
|
||||||
|
const channel = channels.find(c => c.id === result.channelId)
|
||||||
|
if (channel) {
|
||||||
|
// Add to error log
|
||||||
|
const errorEntry: ErrorLogEntry = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
timestamp: Date.now(),
|
||||||
|
channelName: channel.name,
|
||||||
|
channelId: channel.id,
|
||||||
|
errorType: result.errorType || 'UNKNOWN',
|
||||||
|
errorMessage: result.error || 'Bilinmeyen hata',
|
||||||
|
httpStatus: result.httpStatus,
|
||||||
|
notified: true,
|
||||||
|
}
|
||||||
|
addErrorLog(errorEntry)
|
||||||
|
|
||||||
|
// Send notification
|
||||||
|
notificationService.sendNotification(
|
||||||
|
`🔴 ${channel.name} - Yayın Hatası`,
|
||||||
|
result.error || 'Bilinmeyen hata'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log('[App] Services initialized')
|
||||||
|
console.log('[App] Portable mode:', isPortableMode())
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupIpcHandlers() {
|
||||||
|
// Window controls
|
||||||
|
ipcMain.on('window:minimize', () => mainWindow?.minimize())
|
||||||
|
ipcMain.on('window:maximize', () => {
|
||||||
|
if (mainWindow?.isMaximized()) {
|
||||||
|
mainWindow.unmaximize()
|
||||||
|
} else {
|
||||||
|
mainWindow?.maximize()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ipcMain.on('window:close', () => mainWindow?.close())
|
||||||
|
|
||||||
|
// IP Service
|
||||||
|
ipcMain.handle(IPC_CHANNELS.GET_IP_INFO, async () => {
|
||||||
|
return await ipService.getIpInfo()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Channel Management
|
||||||
|
ipcMain.handle(IPC_CHANNELS.PARSE_M3U8, async (_, url: string) => {
|
||||||
|
const newChannels = await m3u8Parser.parse(url)
|
||||||
|
const existingChannels = getChannels()
|
||||||
|
saveChannels([...existingChannels, ...newChannels])
|
||||||
|
return newChannels
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.GET_CHANNELS, async () => {
|
||||||
|
return getChannels()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.UPDATE_CHANNEL, async (_, channel: Channel) => {
|
||||||
|
const channels = getChannels()
|
||||||
|
const index = channels.findIndex(c => c.id === channel.id)
|
||||||
|
if (index !== -1) {
|
||||||
|
channels[index] = channel
|
||||||
|
saveChannels(channels)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.SET_MONITORED, async (_, channelId: string, monitored: boolean) => {
|
||||||
|
const channels = getChannels()
|
||||||
|
const channel = channels.find(c => c.id === channelId)
|
||||||
|
if (channel) {
|
||||||
|
channel.isMonitored = monitored
|
||||||
|
saveChannels(channels)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Monitoring
|
||||||
|
ipcMain.handle(IPC_CHANNELS.START_MONITORING, async () => {
|
||||||
|
const channels = getChannels()
|
||||||
|
const monitoredChannels = channels.filter(c => c.isMonitored)
|
||||||
|
streamChecker.setChannels(monitoredChannels)
|
||||||
|
streamChecker.start()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.STOP_MONITORING, async () => {
|
||||||
|
streamChecker.stop()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.GET_MONITORING_STATUS, async () => {
|
||||||
|
return { isRunning: streamChecker.isRunning() }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Ping
|
||||||
|
ipcMain.handle(IPC_CHANNELS.PING_HOST, async (_, host: string) => {
|
||||||
|
return await pingService.ping(host)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
ipcMain.handle(IPC_CHANNELS.SEND_NOTIFICATION, async (_, title: string, body: string, type?: string) => {
|
||||||
|
await notificationService.sendNotification(title, body, type as 'windows' | 'telegram' | 'email')
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.TEST_NOTIFICATION, async (_, type: string) => {
|
||||||
|
return await notificationService.testNotification(type as 'windows' | 'telegram' | 'email')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Panel API
|
||||||
|
ipcMain.handle(IPC_CHANNELS.PANEL_REGISTER, async (_, config) => {
|
||||||
|
panelService.updateConfig(config)
|
||||||
|
return await panelService.register()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.PANEL_GET_IPS, async () => {
|
||||||
|
return await panelService.getIpPool()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.PANEL_SEND_VOTE, async (_, vote) => {
|
||||||
|
return await panelService.sendBurnVote(vote)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
ipcMain.handle(IPC_CHANNELS.GET_SETTINGS, async () => {
|
||||||
|
return getSettings()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.SAVE_SETTINGS, async (_, settings: AppSettings) => {
|
||||||
|
saveSettings(settings)
|
||||||
|
// Update services with new settings
|
||||||
|
notificationService.updateSettings(settings.notifications)
|
||||||
|
panelService.updateConfig(settings.panel)
|
||||||
|
streamChecker.updateConfig(settings.monitoring)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Error Log
|
||||||
|
ipcMain.handle('errorlog:get', async () => {
|
||||||
|
return getErrorLog()
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle('errorlog:clear', async () => {
|
||||||
|
clearErrorLog()
|
||||||
|
})
|
||||||
|
|
||||||
|
// App Info
|
||||||
|
ipcMain.handle('app:info', async () => {
|
||||||
|
return {
|
||||||
|
version: app.getVersion(),
|
||||||
|
isPortable: isPortableMode(),
|
||||||
|
dataPath: getStore().path,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// App lifecycle
|
||||||
|
app.whenReady().then(() => {
|
||||||
|
// Initialize store first
|
||||||
|
getStore()
|
||||||
|
|
||||||
|
initializeServices()
|
||||||
|
createWindow()
|
||||||
|
createTray()
|
||||||
|
setupIpcHandlers()
|
||||||
|
|
||||||
|
app.on('activate', () => {
|
||||||
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
|
createWindow()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
isAppQuitting = true
|
||||||
|
streamChecker?.stop()
|
||||||
|
})
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { contextBridge, ipcRenderer } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '../shared/types'
|
||||||
|
import type { Channel, AppSettings, BurnVote, NotificationType } from '../shared/types'
|
||||||
|
|
||||||
|
// Expose protected methods to renderer
|
||||||
|
contextBridge.exposeInMainWorld('electron', {
|
||||||
|
// Window controls
|
||||||
|
minimize: () => ipcRenderer.send('window:minimize'),
|
||||||
|
maximize: () => ipcRenderer.send('window:maximize'),
|
||||||
|
close: () => ipcRenderer.send('window:close'),
|
||||||
|
|
||||||
|
// IP Service
|
||||||
|
getIpInfo: () => ipcRenderer.invoke(IPC_CHANNELS.GET_IP_INFO),
|
||||||
|
|
||||||
|
// Channel Management
|
||||||
|
parseM3u8: (url: string) => ipcRenderer.invoke(IPC_CHANNELS.PARSE_M3U8, url),
|
||||||
|
getChannels: () => ipcRenderer.invoke(IPC_CHANNELS.GET_CHANNELS),
|
||||||
|
updateChannel: (channel: Channel) => ipcRenderer.invoke(IPC_CHANNELS.UPDATE_CHANNEL, channel),
|
||||||
|
setChannelMonitored: (channelId: string, monitored: boolean) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.SET_MONITORED, channelId, monitored),
|
||||||
|
|
||||||
|
// Monitoring
|
||||||
|
startMonitoring: () => ipcRenderer.invoke(IPC_CHANNELS.START_MONITORING),
|
||||||
|
stopMonitoring: () => ipcRenderer.invoke(IPC_CHANNELS.STOP_MONITORING),
|
||||||
|
getMonitoringStatus: () => ipcRenderer.invoke(IPC_CHANNELS.GET_MONITORING_STATUS),
|
||||||
|
onMonitoringUpdate: (callback: (result: unknown) => void) => {
|
||||||
|
const handler = (_event: Electron.IpcRendererEvent, result: unknown) => callback(result)
|
||||||
|
ipcRenderer.on(IPC_CHANNELS.MONITORING_UPDATE, handler)
|
||||||
|
return () => ipcRenderer.removeListener(IPC_CHANNELS.MONITORING_UPDATE, handler)
|
||||||
|
},
|
||||||
|
|
||||||
|
// Ping
|
||||||
|
pingHost: (host: string) => ipcRenderer.invoke(IPC_CHANNELS.PING_HOST, host),
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
sendNotification: (title: string, body: string, type?: NotificationType) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.SEND_NOTIFICATION, title, body, type),
|
||||||
|
testNotification: (type: NotificationType) =>
|
||||||
|
ipcRenderer.invoke(IPC_CHANNELS.TEST_NOTIFICATION, type),
|
||||||
|
|
||||||
|
// Panel API
|
||||||
|
panelRegister: (config: unknown) => ipcRenderer.invoke(IPC_CHANNELS.PANEL_REGISTER, config),
|
||||||
|
panelGetIps: () => ipcRenderer.invoke(IPC_CHANNELS.PANEL_GET_IPS),
|
||||||
|
panelSendVote: (vote: BurnVote) => ipcRenderer.invoke(IPC_CHANNELS.PANEL_SEND_VOTE, vote),
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
getSettings: () => ipcRenderer.invoke(IPC_CHANNELS.GET_SETTINGS),
|
||||||
|
saveSettings: (settings: AppSettings) => ipcRenderer.invoke(IPC_CHANNELS.SAVE_SETTINGS, settings),
|
||||||
|
|
||||||
|
// Error Log
|
||||||
|
getErrorLog: () => ipcRenderer.invoke('errorlog:get'),
|
||||||
|
clearErrorLog: () => ipcRenderer.invoke('errorlog:clear'),
|
||||||
|
|
||||||
|
// App Info
|
||||||
|
getAppInfo: () => ipcRenderer.invoke('app:info'),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import * as nodemailer from 'nodemailer'
|
||||||
|
|
||||||
|
export interface EmailConfig {
|
||||||
|
smtpHost: string
|
||||||
|
smtpPort: number
|
||||||
|
smtpUser: string
|
||||||
|
smtpPass: string
|
||||||
|
fromEmail: string
|
||||||
|
toEmail: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EmailService {
|
||||||
|
private config: EmailConfig
|
||||||
|
private transporter: nodemailer.Transporter | null = null
|
||||||
|
|
||||||
|
constructor(config: EmailConfig) {
|
||||||
|
this.config = config
|
||||||
|
this.initTransporter()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig(config: EmailConfig): void {
|
||||||
|
this.config = config
|
||||||
|
this.initTransporter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private initTransporter(): void {
|
||||||
|
if (!this.config.smtpHost || !this.config.smtpUser) {
|
||||||
|
this.transporter = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.transporter = nodemailer.createTransport({
|
||||||
|
host: this.config.smtpHost,
|
||||||
|
port: this.config.smtpPort,
|
||||||
|
secure: this.config.smtpPort === 465,
|
||||||
|
auth: {
|
||||||
|
user: this.config.smtpUser,
|
||||||
|
pass: this.config.smtpPass,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendEmail(subject: string, text: string, html?: string): Promise<boolean> {
|
||||||
|
if (!this.transporter) {
|
||||||
|
console.warn('Email transporter not configured')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.transporter.sendMail({
|
||||||
|
from: this.config.fromEmail || this.config.smtpUser,
|
||||||
|
to: this.config.toEmail,
|
||||||
|
subject: `[IP Monitor] ${subject}`,
|
||||||
|
text,
|
||||||
|
html: html || this.generateHtml(subject, text),
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send email:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendAlert(title: string, message: string): Promise<boolean> {
|
||||||
|
return this.sendEmail(title, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.transporter) {
|
||||||
|
return { success: false, error: 'Email not configured' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.transporter.verify()
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateHtml(title: string, content: string): string {
|
||||||
|
return `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: #0f0f12;
|
||||||
|
color: #e4e4e7;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #18181b;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 30px;
|
||||||
|
border: 1px solid #27272a;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #e06c75;
|
||||||
|
border-bottom: 2px solid #e06c75;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
background: #1e1e22;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #27272a;
|
||||||
|
}
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
color: #a1a1aa;
|
||||||
|
}
|
||||||
|
.footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
color: #71717a;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>${title}</h1>
|
||||||
|
<div class="content">
|
||||||
|
<pre>${content}</pre>
|
||||||
|
</div>
|
||||||
|
<p class="footer">
|
||||||
|
IP Monitor - ${new Date().toLocaleString('tr-TR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,76 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
import type { IpInfo } from '../../shared/types'
|
||||||
|
|
||||||
|
export class IpService {
|
||||||
|
private cache: IpInfo | null = null
|
||||||
|
private cacheTime: number = 0
|
||||||
|
private cacheDuration = 5 * 60 * 1000 // 5 minutes
|
||||||
|
|
||||||
|
async getIpInfo(): Promise<IpInfo> {
|
||||||
|
// Return cached data if still valid
|
||||||
|
if (this.cache && Date.now() - this.cacheTime < this.cacheDuration) {
|
||||||
|
return this.cache
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try ip-api.com first (more detailed info, free)
|
||||||
|
const response = await axios.get('http://ip-api.com/json/', {
|
||||||
|
timeout: 10000,
|
||||||
|
params: {
|
||||||
|
fields: 'status,message,country,countryCode,city,isp,query,timezone'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.status === 'success') {
|
||||||
|
const info: IpInfo = {
|
||||||
|
ip: response.data.query,
|
||||||
|
country: response.data.country,
|
||||||
|
countryCode: response.data.countryCode,
|
||||||
|
city: response.data.city,
|
||||||
|
isp: response.data.isp,
|
||||||
|
timezone: response.data.timezone
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache = info
|
||||||
|
this.cacheTime = Date.now()
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ip-api.com failed:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to ipify + ipapi
|
||||||
|
try {
|
||||||
|
const ipResponse = await axios.get('https://api.ipify.org?format=json', {
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
const ip = ipResponse.data.ip
|
||||||
|
|
||||||
|
const geoResponse = await axios.get(`https://ipapi.co/${ip}/json/`, {
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
|
||||||
|
const info: IpInfo = {
|
||||||
|
ip: ip,
|
||||||
|
country: geoResponse.data.country_name || 'Unknown',
|
||||||
|
countryCode: geoResponse.data.country_code || 'XX',
|
||||||
|
city: geoResponse.data.city,
|
||||||
|
isp: geoResponse.data.org,
|
||||||
|
timezone: geoResponse.data.timezone
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache = info
|
||||||
|
this.cacheTime = Date.now()
|
||||||
|
return info
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Fallback IP lookup failed:', error)
|
||||||
|
throw new Error('Failed to get IP information')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache(): void {
|
||||||
|
this.cache = null
|
||||||
|
this.cacheTime = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,196 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
import type { Channel } from '../../shared/types'
|
||||||
|
import { randomUUID } from 'crypto'
|
||||||
|
|
||||||
|
interface M3u8Entry {
|
||||||
|
name: string
|
||||||
|
url: string
|
||||||
|
group?: string
|
||||||
|
logo?: string
|
||||||
|
tvgId?: string
|
||||||
|
tvgName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class M3u8Parser {
|
||||||
|
/**
|
||||||
|
* Parse M3U8/M3U playlist from URL
|
||||||
|
*/
|
||||||
|
async parse(url: string): Promise<Channel[]> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
timeout: 30000,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
|
},
|
||||||
|
responseType: 'text'
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = response.data as string
|
||||||
|
return this.parseContent(content)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch M3U8:', error)
|
||||||
|
throw new Error(`Failed to fetch playlist: ${(error as Error).message}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse M3U8/M3U content string
|
||||||
|
*/
|
||||||
|
parseContent(content: string): Channel[] {
|
||||||
|
const lines = content.split('\n').map(line => line.trim())
|
||||||
|
const entries: M3u8Entry[] = []
|
||||||
|
|
||||||
|
let currentEntry: Partial<M3u8Entry> | null = null
|
||||||
|
|
||||||
|
for (let i = 0; i < lines.length; i++) {
|
||||||
|
const line = lines[i]
|
||||||
|
|
||||||
|
// Skip empty lines and M3U header
|
||||||
|
if (!line || line === '#EXTM3U') continue
|
||||||
|
|
||||||
|
// Parse EXTINF line
|
||||||
|
if (line.startsWith('#EXTINF:')) {
|
||||||
|
currentEntry = this.parseExtInf(line)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse additional attributes
|
||||||
|
if (line.startsWith('#EXTGRP:') && currentEntry) {
|
||||||
|
currentEntry.group = line.replace('#EXTGRP:', '').trim()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip other comments
|
||||||
|
if (line.startsWith('#')) continue
|
||||||
|
|
||||||
|
// This should be a URL
|
||||||
|
if (currentEntry && this.isValidUrl(line)) {
|
||||||
|
currentEntry.url = line
|
||||||
|
entries.push(currentEntry as M3u8Entry)
|
||||||
|
currentEntry = null
|
||||||
|
} else if (this.isValidUrl(line)) {
|
||||||
|
// URL without EXTINF
|
||||||
|
entries.push({
|
||||||
|
name: this.extractNameFromUrl(line),
|
||||||
|
url: line
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.map(entry => this.entryToChannel(entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse #EXTINF line and extract metadata
|
||||||
|
*/
|
||||||
|
private parseExtInf(line: string): Partial<M3u8Entry> {
|
||||||
|
const entry: Partial<M3u8Entry> = {}
|
||||||
|
|
||||||
|
// Remove #EXTINF: prefix
|
||||||
|
let content = line.replace('#EXTINF:', '')
|
||||||
|
|
||||||
|
// Extract duration (usually -1 for live streams)
|
||||||
|
const durationMatch = content.match(/^-?\d+/)
|
||||||
|
if (durationMatch) {
|
||||||
|
content = content.substring(durationMatch[0].length)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse attributes in format: key="value"
|
||||||
|
const attrRegex = /(\w+(?:-\w+)*)=["']([^"']*)["']/g
|
||||||
|
let match
|
||||||
|
|
||||||
|
while ((match = attrRegex.exec(content)) !== null) {
|
||||||
|
const [, key, value] = match
|
||||||
|
|
||||||
|
switch (key.toLowerCase()) {
|
||||||
|
case 'tvg-id':
|
||||||
|
entry.tvgId = value
|
||||||
|
break
|
||||||
|
case 'tvg-name':
|
||||||
|
entry.tvgName = value
|
||||||
|
break
|
||||||
|
case 'tvg-logo':
|
||||||
|
case 'logo':
|
||||||
|
entry.logo = value
|
||||||
|
break
|
||||||
|
case 'group-title':
|
||||||
|
entry.group = value
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract channel name (after last comma)
|
||||||
|
const nameMatch = content.match(/,(.+)$/)
|
||||||
|
if (nameMatch) {
|
||||||
|
entry.name = nameMatch[1].trim()
|
||||||
|
} else {
|
||||||
|
// Try to use tvg-name if no name after comma
|
||||||
|
entry.name = entry.tvgName || 'Unknown Channel'
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert M3U entry to Channel object
|
||||||
|
*/
|
||||||
|
private entryToChannel(entry: M3u8Entry): Channel {
|
||||||
|
return {
|
||||||
|
id: randomUUID(),
|
||||||
|
name: entry.name,
|
||||||
|
url: entry.url,
|
||||||
|
group: entry.group,
|
||||||
|
logo: entry.logo,
|
||||||
|
isMonitored: false,
|
||||||
|
status: 'unknown'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if string is a valid URL
|
||||||
|
*/
|
||||||
|
private isValidUrl(str: string): boolean {
|
||||||
|
try {
|
||||||
|
new URL(str)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract channel name from URL
|
||||||
|
*/
|
||||||
|
private extractNameFromUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
const pathParts = parsed.pathname.split('/')
|
||||||
|
const lastPart = pathParts[pathParts.length - 1] || pathParts[pathParts.length - 2]
|
||||||
|
|
||||||
|
// Remove file extension
|
||||||
|
const name = lastPart.replace(/\.(m3u8?|ts|mp4)$/i, '')
|
||||||
|
|
||||||
|
// Clean up the name
|
||||||
|
return name
|
||||||
|
.replace(/[_-]/g, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim() || parsed.hostname
|
||||||
|
} catch {
|
||||||
|
return 'Unknown Channel'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unique groups from channels
|
||||||
|
*/
|
||||||
|
static getGroups(channels: Channel[]): string[] {
|
||||||
|
const groups = new Set<string>()
|
||||||
|
channels.forEach(channel => {
|
||||||
|
if (channel.group) {
|
||||||
|
groups.add(channel.group)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return Array.from(groups).sort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,211 @@
|
||||||
|
import { Notification, app } from 'electron'
|
||||||
|
import axios from 'axios'
|
||||||
|
import * as nodemailer from 'nodemailer'
|
||||||
|
import type { NotificationSettings, NotificationType } from '../../shared/types'
|
||||||
|
|
||||||
|
export class NotificationService {
|
||||||
|
private settings: NotificationSettings
|
||||||
|
private emailTransporter: nodemailer.Transporter | null = null
|
||||||
|
|
||||||
|
constructor(settings: NotificationSettings) {
|
||||||
|
this.settings = settings
|
||||||
|
this.initializeEmailTransporter()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSettings(settings: NotificationSettings): void {
|
||||||
|
this.settings = settings
|
||||||
|
this.initializeEmailTransporter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeEmailTransporter(): void {
|
||||||
|
if (this.settings.email.enabled && this.settings.email.smtpHost) {
|
||||||
|
this.emailTransporter = nodemailer.createTransport({
|
||||||
|
host: this.settings.email.smtpHost,
|
||||||
|
port: this.settings.email.smtpPort,
|
||||||
|
secure: this.settings.email.smtpPort === 465,
|
||||||
|
auth: {
|
||||||
|
user: this.settings.email.smtpUser,
|
||||||
|
pass: this.settings.email.smtpPass,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification through all enabled channels
|
||||||
|
*/
|
||||||
|
async sendNotification(title: string, body: string, specificType?: NotificationType): Promise<void> {
|
||||||
|
if (!this.settings.enabled) return
|
||||||
|
|
||||||
|
const promises: Promise<void>[] = []
|
||||||
|
|
||||||
|
if (specificType) {
|
||||||
|
// Send to specific channel
|
||||||
|
switch (specificType) {
|
||||||
|
case 'windows':
|
||||||
|
if (this.settings.windows.enabled) {
|
||||||
|
promises.push(this.sendWindowsNotification(title, body))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'telegram':
|
||||||
|
if (this.settings.telegram.enabled) {
|
||||||
|
promises.push(this.sendTelegramNotification(title, body))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'email':
|
||||||
|
if (this.settings.email.enabled) {
|
||||||
|
promises.push(this.sendEmailNotification(title, body))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Send to all enabled channels
|
||||||
|
if (this.settings.windows.enabled) {
|
||||||
|
promises.push(this.sendWindowsNotification(title, body))
|
||||||
|
}
|
||||||
|
if (this.settings.telegram.enabled) {
|
||||||
|
promises.push(this.sendTelegramNotification(title, body))
|
||||||
|
}
|
||||||
|
if (this.settings.email.enabled) {
|
||||||
|
promises.push(this.sendEmailNotification(title, body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.allSettled(promises)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send Windows toast notification
|
||||||
|
*/
|
||||||
|
async sendWindowsNotification(title: string, body: string): Promise<void> {
|
||||||
|
if (!Notification.isSupported()) {
|
||||||
|
console.warn('Notifications not supported on this system')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const notification = new Notification({
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
icon: undefined, // Will use app icon
|
||||||
|
silent: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
notification.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send Telegram notification
|
||||||
|
*/
|
||||||
|
async sendTelegramNotification(title: string, body: string): Promise<void> {
|
||||||
|
const { botToken, chatId } = this.settings.telegram
|
||||||
|
|
||||||
|
if (!botToken || !chatId) {
|
||||||
|
throw new Error('Telegram bot token or chat ID not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = `*${this.escapeMarkdown(title)}*\n\n${this.escapeMarkdown(body)}`
|
||||||
|
|
||||||
|
const url = `https://api.telegram.org/bot${botToken}/sendMessage`
|
||||||
|
|
||||||
|
await axios.post(url, {
|
||||||
|
chat_id: chatId,
|
||||||
|
text: message,
|
||||||
|
parse_mode: 'Markdown',
|
||||||
|
disable_web_page_preview: true,
|
||||||
|
}, {
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send email notification
|
||||||
|
*/
|
||||||
|
async sendEmailNotification(title: string, body: string): Promise<void> {
|
||||||
|
if (!this.emailTransporter) {
|
||||||
|
throw new Error('Email transporter not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
const { fromEmail, toEmail } = this.settings.email
|
||||||
|
|
||||||
|
if (!fromEmail || !toEmail) {
|
||||||
|
throw new Error('Email addresses not configured')
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.emailTransporter.sendMail({
|
||||||
|
from: fromEmail,
|
||||||
|
to: toEmail,
|
||||||
|
subject: `[IP Monitor] ${title}`,
|
||||||
|
text: body,
|
||||||
|
html: `
|
||||||
|
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||||
|
<h2 style="color: #e06c75; border-bottom: 2px solid #e06c75; padding-bottom: 10px;">
|
||||||
|
${title}
|
||||||
|
</h2>
|
||||||
|
<div style="background: #f5f5f5; padding: 15px; border-radius: 8px; margin-top: 15px;">
|
||||||
|
<pre style="white-space: pre-wrap; font-family: 'Courier New', monospace;">${body}</pre>
|
||||||
|
</div>
|
||||||
|
<p style="color: #888; font-size: 12px; margin-top: 20px;">
|
||||||
|
Sent by IP Monitor at ${new Date().toLocaleString('tr-TR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test notification for a specific channel
|
||||||
|
*/
|
||||||
|
async testNotification(type: NotificationType): Promise<{ success: boolean; error?: string }> {
|
||||||
|
const title = 'Test Bildirimi'
|
||||||
|
const body = `Bu bir test bildirimidir.\nZaman: ${new Date().toLocaleString('tr-TR')}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (type) {
|
||||||
|
case 'windows':
|
||||||
|
await this.sendWindowsNotification(title, body)
|
||||||
|
break
|
||||||
|
case 'telegram':
|
||||||
|
await this.sendTelegramNotification(title, body)
|
||||||
|
break
|
||||||
|
case 'email':
|
||||||
|
await this.sendEmailNotification(title, body)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return { success: true }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape special characters for Telegram Markdown
|
||||||
|
*/
|
||||||
|
private escapeMarkdown(text: string): string {
|
||||||
|
return text.replace(/[_*[\]()~`>#+=|{}.!-]/g, '\\$&')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format error for notification
|
||||||
|
*/
|
||||||
|
static formatErrorNotification(
|
||||||
|
channelName: string,
|
||||||
|
errorType: string,
|
||||||
|
errorMessage: string,
|
||||||
|
httpStatus?: number
|
||||||
|
): { title: string; body: string } {
|
||||||
|
let statusEmoji = '🔴'
|
||||||
|
if (errorType === 'TIMEOUT') statusEmoji = '⏱️'
|
||||||
|
if (errorType === 'CONNRESET') statusEmoji = '🔌'
|
||||||
|
if (httpStatus === 404) statusEmoji = '❌'
|
||||||
|
|
||||||
|
const title = `${statusEmoji} ${channelName} - Yayın Hatası`
|
||||||
|
|
||||||
|
let body = `Hata Tipi: ${errorType}\n`
|
||||||
|
if (httpStatus) body += `HTTP Status: ${httpStatus}\n`
|
||||||
|
body += `Detay: ${errorMessage}\n`
|
||||||
|
body += `Zaman: ${new Date().toLocaleString('tr-TR')}`
|
||||||
|
|
||||||
|
return { title, body }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,187 @@
|
||||||
|
import axios, { AxiosInstance } from 'axios'
|
||||||
|
import type { PanelConfig, PanelIpPool, BurnVote } from '../../shared/types'
|
||||||
|
|
||||||
|
export class PanelService {
|
||||||
|
private config: PanelConfig
|
||||||
|
private client: AxiosInstance | null = null
|
||||||
|
|
||||||
|
constructor(config: PanelConfig) {
|
||||||
|
this.config = config
|
||||||
|
this.initializeClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig(config: PanelConfig): void {
|
||||||
|
this.config = config
|
||||||
|
this.initializeClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeClient(): void {
|
||||||
|
if (!this.config.apiUrl) {
|
||||||
|
this.client = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.client = axios.create({
|
||||||
|
baseURL: this.config.apiUrl,
|
||||||
|
timeout: 10000,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(this.config.authToken && {
|
||||||
|
'Authorization': `Bearer ${this.config.authToken}`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register monitor with panel
|
||||||
|
* Based on MONITOR_API_GUIDE.md
|
||||||
|
*/
|
||||||
|
async register(): Promise<{ success: boolean; authToken?: string; error?: string }> {
|
||||||
|
if (!this.client) {
|
||||||
|
return { success: false, error: 'Panel not configured' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.post('/api.php?endpoint=servers', {
|
||||||
|
server_id: this.config.monitorId,
|
||||||
|
server_type: 'monitor',
|
||||||
|
hostname: require('os').hostname(),
|
||||||
|
ip_address: await this.getLocalIp(),
|
||||||
|
country_code: 'TR' // Could be detected dynamically
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
authToken: response.data.auth_token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: response.data.message }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get IP pool from panel
|
||||||
|
* Based on MONITOR_API_GUIDE.md - Option A: REST API (Polling)
|
||||||
|
*/
|
||||||
|
async getIpPool(): Promise<PanelIpPool> {
|
||||||
|
if (!this.client || !this.config.enabled) {
|
||||||
|
return { active: [], honeypot: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.get('/api.php', {
|
||||||
|
params: {
|
||||||
|
endpoint: 'ip-pool',
|
||||||
|
for_monitor: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
return response.data.ips || { active: [], honeypot: [] }
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Failed to get IP pool:', response.data.message)
|
||||||
|
return { active: [], honeypot: [] }
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch IP pool:', error)
|
||||||
|
return { active: [], honeypot: [] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send burn vote
|
||||||
|
* Based on MONITOR_API_GUIDE.md - Section 2: Burn Bildirimi
|
||||||
|
*/
|
||||||
|
async sendBurnVote(vote: BurnVote): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.client || !this.config.enabled) {
|
||||||
|
return { success: false, error: 'Panel not enabled' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.client.post('/api.php?endpoint=burn-vote', {
|
||||||
|
ip_address: vote.ip_address,
|
||||||
|
vote: vote.vote,
|
||||||
|
reason: vote.reason,
|
||||||
|
evidence: {
|
||||||
|
...vote.evidence,
|
||||||
|
monitor_id: this.config.monitorId,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
console.log('Burn vote submitted:', {
|
||||||
|
ip: vote.ip_address,
|
||||||
|
vote: vote.vote,
|
||||||
|
current_votes: response.data.current_votes
|
||||||
|
})
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: false, error: response.data.message }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Report IP health status
|
||||||
|
*/
|
||||||
|
async reportHealth(
|
||||||
|
ipAddress: string,
|
||||||
|
isWorking: boolean,
|
||||||
|
checkResult: {
|
||||||
|
checkType: string
|
||||||
|
port?: number
|
||||||
|
latencyMs?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
): Promise<void> {
|
||||||
|
const vote: BurnVote = {
|
||||||
|
ip_address: ipAddress,
|
||||||
|
vote: isWorking ? 'ok' : 'burn',
|
||||||
|
reason: isWorking ? 'IP working normally' : checkResult.error || 'Check failed',
|
||||||
|
evidence: {
|
||||||
|
check_type: checkResult.checkType,
|
||||||
|
port: checkResult.port,
|
||||||
|
response_time_ms: checkResult.latencyMs,
|
||||||
|
error_message: checkResult.error,
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
monitor_id: this.config.monitorId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sendBurnVote(vote)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get local IP address
|
||||||
|
*/
|
||||||
|
private async getLocalIp(): Promise<string> {
|
||||||
|
const os = require('os')
|
||||||
|
const interfaces = os.networkInterfaces()
|
||||||
|
|
||||||
|
for (const name of Object.keys(interfaces)) {
|
||||||
|
for (const iface of interfaces[name] || []) {
|
||||||
|
if (iface.family === 'IPv4' && !iface.internal) {
|
||||||
|
return iface.address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '127.0.0.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if panel is configured and enabled
|
||||||
|
*/
|
||||||
|
isEnabled(): boolean {
|
||||||
|
return this.config.enabled && !!this.config.apiUrl && !!this.config.authToken
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
import * as net from 'net'
|
||||||
|
import * as dns from 'dns'
|
||||||
|
import { promisify } from 'util'
|
||||||
|
import type { PingResult } from '../../shared/types'
|
||||||
|
|
||||||
|
const dnsLookup = promisify(dns.lookup)
|
||||||
|
|
||||||
|
export class PingService {
|
||||||
|
private timeout: number
|
||||||
|
|
||||||
|
constructor(timeout: number = 5000) {
|
||||||
|
this.timeout = timeout
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping a host using TCP connection (works without admin privileges)
|
||||||
|
*/
|
||||||
|
async ping(host: string, port: number = 80): Promise<PingResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Resolve hostname first
|
||||||
|
let ip = host
|
||||||
|
if (!this.isIpAddress(host)) {
|
||||||
|
try {
|
||||||
|
const result = await dnsLookup(host)
|
||||||
|
ip = result.address
|
||||||
|
} catch (dnsError) {
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
alive: false,
|
||||||
|
error: `DNS lookup failed: ${(dnsError as Error).message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TCP connection test
|
||||||
|
const alive = await this.tcpPing(ip, port)
|
||||||
|
const time = Date.now() - startTime
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
alive,
|
||||||
|
time: alive ? time : undefined,
|
||||||
|
error: alive ? undefined : 'Connection failed'
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
alive: false,
|
||||||
|
error: (error as Error).message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private tcpPing(host: string, port: number): Promise<boolean> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = new net.Socket()
|
||||||
|
|
||||||
|
socket.setTimeout(this.timeout)
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.destroy()
|
||||||
|
resolve(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('timeout', () => {
|
||||||
|
socket.destroy()
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('error', () => {
|
||||||
|
socket.destroy()
|
||||||
|
resolve(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.connect(port, host)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping multiple hosts in parallel
|
||||||
|
*/
|
||||||
|
async pingMultiple(hosts: string[], port: number = 80): Promise<PingResult[]> {
|
||||||
|
const results = await Promise.all(
|
||||||
|
hosts.map(host => this.ping(host, port))
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if string is an IP address
|
||||||
|
*/
|
||||||
|
private isIpAddress(str: string): boolean {
|
||||||
|
const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/
|
||||||
|
const ipv6Regex = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/
|
||||||
|
return ipv4Regex.test(str) || ipv6Regex.test(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract host from URL
|
||||||
|
*/
|
||||||
|
static extractHost(url: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return parsed.hostname
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTimeout(timeout: number): void {
|
||||||
|
this.timeout = timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
import axios, { AxiosError } from 'axios'
|
||||||
|
import type { Channel, MonitoringResult, ChannelStatus } from '../../shared/types'
|
||||||
|
|
||||||
|
interface MonitoringConfig {
|
||||||
|
pingInterval: number
|
||||||
|
streamCheckInterval: number
|
||||||
|
timeout: number
|
||||||
|
retryCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type MonitoringCallback = (result: MonitoringResult) => void
|
||||||
|
|
||||||
|
export class StreamChecker {
|
||||||
|
private config: MonitoringConfig
|
||||||
|
private channels: Channel[] = []
|
||||||
|
private callback: MonitoringCallback
|
||||||
|
private intervalId: NodeJS.Timeout | null = null
|
||||||
|
private running = false
|
||||||
|
|
||||||
|
constructor(config: MonitoringConfig, callback: MonitoringCallback) {
|
||||||
|
this.config = config
|
||||||
|
this.callback = callback
|
||||||
|
}
|
||||||
|
|
||||||
|
setChannels(channels: Channel[]): void {
|
||||||
|
this.channels = channels
|
||||||
|
}
|
||||||
|
|
||||||
|
start(): void {
|
||||||
|
if (this.running) return
|
||||||
|
|
||||||
|
this.running = true
|
||||||
|
this.checkAllChannels()
|
||||||
|
|
||||||
|
this.intervalId = setInterval(() => {
|
||||||
|
this.checkAllChannels()
|
||||||
|
}, this.config.streamCheckInterval * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
stop(): void {
|
||||||
|
this.running = false
|
||||||
|
if (this.intervalId) {
|
||||||
|
clearInterval(this.intervalId)
|
||||||
|
this.intervalId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isRunning(): boolean {
|
||||||
|
return this.running
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig(config: MonitoringConfig): void {
|
||||||
|
this.config = config
|
||||||
|
|
||||||
|
// Restart if running with new interval
|
||||||
|
if (this.running) {
|
||||||
|
this.stop()
|
||||||
|
this.start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkAllChannels(): Promise<void> {
|
||||||
|
for (const channel of this.channels) {
|
||||||
|
if (!this.running) break
|
||||||
|
|
||||||
|
const result = await this.checkChannel(channel)
|
||||||
|
this.callback(result)
|
||||||
|
|
||||||
|
// Small delay between checks to prevent overwhelming
|
||||||
|
await this.delay(500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkChannel(channel: Channel): Promise<MonitoringResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// Notify checking status
|
||||||
|
this.callback({
|
||||||
|
channelId: channel.id,
|
||||||
|
timestamp: startTime,
|
||||||
|
status: 'checking'
|
||||||
|
})
|
||||||
|
|
||||||
|
let lastError: string | undefined
|
||||||
|
let errorType: MonitoringResult['errorType']
|
||||||
|
let httpStatus: number | undefined
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < this.config.retryCount; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await axios.head(channel.url, {
|
||||||
|
timeout: this.config.timeout,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
|
},
|
||||||
|
maxRedirects: 5,
|
||||||
|
validateStatus: () => true // Accept any status
|
||||||
|
})
|
||||||
|
|
||||||
|
httpStatus = response.status
|
||||||
|
const latency = Date.now() - startTime
|
||||||
|
|
||||||
|
// Check for successful response
|
||||||
|
if (response.status >= 200 && response.status < 400) {
|
||||||
|
return {
|
||||||
|
channelId: channel.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
status: 'online',
|
||||||
|
latency,
|
||||||
|
httpStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP error status
|
||||||
|
lastError = `HTTP ${response.status} ${response.statusText}`
|
||||||
|
errorType = 'HTTP_ERROR'
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return {
|
||||||
|
channelId: channel.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
status: 'offline',
|
||||||
|
httpStatus,
|
||||||
|
error: lastError,
|
||||||
|
errorType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as AxiosError
|
||||||
|
|
||||||
|
if (axiosError.code === 'ECONNRESET') {
|
||||||
|
lastError = 'ERR_CONNECTION_RESET'
|
||||||
|
errorType = 'CONNRESET'
|
||||||
|
} else if (axiosError.code === 'ECONNREFUSED') {
|
||||||
|
lastError = 'ERR_CONNECTION_REFUSED'
|
||||||
|
errorType = 'CONNREFUSED'
|
||||||
|
} else if (axiosError.code === 'ETIMEDOUT' || axiosError.code === 'ECONNABORTED') {
|
||||||
|
lastError = 'Connection timeout'
|
||||||
|
errorType = 'TIMEOUT'
|
||||||
|
} else if (axiosError.code === 'ENOTFOUND') {
|
||||||
|
lastError = 'DNS lookup failed'
|
||||||
|
errorType = 'UNKNOWN'
|
||||||
|
} else {
|
||||||
|
lastError = axiosError.message
|
||||||
|
errorType = 'UNKNOWN'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait before retry
|
||||||
|
if (attempt < this.config.retryCount - 1) {
|
||||||
|
await this.delay(1000 * (attempt + 1)) // Exponential backoff
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All retries failed
|
||||||
|
return {
|
||||||
|
channelId: channel.id,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
status: 'error',
|
||||||
|
httpStatus,
|
||||||
|
error: lastError,
|
||||||
|
errorType
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if M3U8 playlist is valid and can be parsed
|
||||||
|
*/
|
||||||
|
async checkM3u8Content(url: string): Promise<{ valid: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
timeout: this.config.timeout,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||||
|
},
|
||||||
|
maxRedirects: 5
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = response.data as string
|
||||||
|
|
||||||
|
// Check for M3U8 header
|
||||||
|
if (!content.includes('#EXTM3U')) {
|
||||||
|
return { valid: false, error: 'Not a valid M3U8 file' }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for segments or variants
|
||||||
|
const hasContent = content.includes('#EXTINF') || content.includes('#EXT-X-STREAM-INF')
|
||||||
|
|
||||||
|
if (!hasContent) {
|
||||||
|
return { valid: false, error: 'M3U8 file has no content' }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true }
|
||||||
|
} catch (error) {
|
||||||
|
const axiosError = error as AxiosError
|
||||||
|
return { valid: false, error: axiosError.message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private delay(ms: number): Promise<void> {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
export interface TelegramConfig {
|
||||||
|
botToken: string
|
||||||
|
chatId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TelegramBot {
|
||||||
|
private config: TelegramConfig
|
||||||
|
private baseUrl: string
|
||||||
|
|
||||||
|
constructor(config: TelegramConfig) {
|
||||||
|
this.config = config
|
||||||
|
this.baseUrl = `https://api.telegram.org/bot${config.botToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
updateConfig(config: TelegramConfig): void {
|
||||||
|
this.config = config
|
||||||
|
this.baseUrl = `https://api.telegram.org/bot${config.botToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(text: string, parseMode: 'Markdown' | 'HTML' = 'Markdown'): Promise<boolean> {
|
||||||
|
if (!this.config.botToken || !this.config.chatId) {
|
||||||
|
console.warn('Telegram bot not configured')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post(`${this.baseUrl}/sendMessage`, {
|
||||||
|
chat_id: this.config.chatId,
|
||||||
|
text,
|
||||||
|
parse_mode: parseMode,
|
||||||
|
disable_web_page_preview: true
|
||||||
|
}, {
|
||||||
|
timeout: 10000
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send Telegram message:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendAlert(title: string, message: string): Promise<boolean> {
|
||||||
|
const formattedMessage = `*${this.escapeMarkdown(title)}*\n\n${this.escapeMarkdown(message)}`
|
||||||
|
return this.sendMessage(formattedMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
async testConnection(): Promise<{ success: boolean; error?: string }> {
|
||||||
|
if (!this.config.botToken) {
|
||||||
|
return { success: false, error: 'Bot token not configured' }
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${this.baseUrl}/getMe`, { timeout: 10000 })
|
||||||
|
if (response.data.ok) {
|
||||||
|
return { success: true }
|
||||||
|
}
|
||||||
|
return { success: false, error: 'Invalid bot token' }
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: (error as Error).message }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private escapeMarkdown(text: string): string {
|
||||||
|
return text.replace(/[_*[\]()~`>#+=|{}.!-]/g, '\\$&')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,204 @@
|
||||||
|
import Store from 'electron-store'
|
||||||
|
import { app } from 'electron'
|
||||||
|
import * as path from 'path'
|
||||||
|
import * as fs from 'fs'
|
||||||
|
import type { AppSettings, Channel, ErrorLogEntry } from '../shared/types'
|
||||||
|
|
||||||
|
// Default settings
|
||||||
|
export const defaultSettings: AppSettings = {
|
||||||
|
general: {
|
||||||
|
startMinimized: false,
|
||||||
|
minimizeToTray: true,
|
||||||
|
autoStart: false,
|
||||||
|
language: 'tr',
|
||||||
|
},
|
||||||
|
monitoring: {
|
||||||
|
pingInterval: 30,
|
||||||
|
streamCheckInterval: 60,
|
||||||
|
timeout: 5000,
|
||||||
|
retryCount: 2,
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
enabled: true,
|
||||||
|
windows: { enabled: true },
|
||||||
|
telegram: {
|
||||||
|
enabled: false,
|
||||||
|
botToken: '',
|
||||||
|
chatId: '',
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
enabled: false,
|
||||||
|
smtpHost: '',
|
||||||
|
smtpPort: 587,
|
||||||
|
smtpUser: '',
|
||||||
|
smtpPass: '',
|
||||||
|
fromEmail: '',
|
||||||
|
toEmail: '',
|
||||||
|
},
|
||||||
|
autoNotify: true,
|
||||||
|
notifyOnPingFail: true,
|
||||||
|
notifyOnStreamError: true,
|
||||||
|
notifyOn404: true,
|
||||||
|
notifyOnConnReset: true,
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
apiUrl: '',
|
||||||
|
authToken: '',
|
||||||
|
monitorId: '',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if running in portable mode
|
||||||
|
function isPortableMode(): boolean {
|
||||||
|
// Check for portable marker file next to exe
|
||||||
|
const exePath = app.getPath('exe')
|
||||||
|
const exeDir = path.dirname(exePath)
|
||||||
|
const portableMarker = path.join(exeDir, 'portable.txt')
|
||||||
|
const portableDataDir = path.join(exeDir, 'data')
|
||||||
|
|
||||||
|
// Portable mode if:
|
||||||
|
// 1. portable.txt exists next to exe
|
||||||
|
// 2. OR data folder exists next to exe
|
||||||
|
// 3. OR running from a removable drive (simplified check)
|
||||||
|
if (fs.existsSync(portableMarker) || fs.existsSync(portableDataDir)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if exe name contains "Portable"
|
||||||
|
if (exePath.toLowerCase().includes('portable')) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the data directory for storing config
|
||||||
|
function getDataDirectory(): string {
|
||||||
|
if (isPortableMode()) {
|
||||||
|
const exeDir = path.dirname(app.getPath('exe'))
|
||||||
|
const dataDir = path.join(exeDir, 'data')
|
||||||
|
|
||||||
|
// Create data directory if it doesn't exist
|
||||||
|
if (!fs.existsSync(dataDir)) {
|
||||||
|
fs.mkdirSync(dataDir, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
return dataDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use default app data location
|
||||||
|
return app.getPath('userData')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store schema
|
||||||
|
interface StoreSchema {
|
||||||
|
settings: AppSettings
|
||||||
|
channels: Channel[]
|
||||||
|
errorLog: ErrorLogEntry[]
|
||||||
|
windowBounds: {
|
||||||
|
x?: number
|
||||||
|
y?: number
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create store instance
|
||||||
|
let storeInstance: Store<StoreSchema> | null = null
|
||||||
|
|
||||||
|
export function getStore(): Store<StoreSchema> {
|
||||||
|
if (!storeInstance) {
|
||||||
|
const dataDir = getDataDirectory()
|
||||||
|
|
||||||
|
storeInstance = new Store<StoreSchema>({
|
||||||
|
name: 'ip-monitor-config',
|
||||||
|
cwd: dataDir,
|
||||||
|
defaults: {
|
||||||
|
settings: defaultSettings,
|
||||||
|
channels: [],
|
||||||
|
errorLog: [],
|
||||||
|
windowBounds: {
|
||||||
|
width: 1400,
|
||||||
|
height: 900,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Enable encryption for sensitive data
|
||||||
|
encryptionKey: 'ip-monitor-v1-key',
|
||||||
|
// Clear invalid config
|
||||||
|
clearInvalidConfig: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(`[Store] Config location: ${dataDir}`)
|
||||||
|
console.log(`[Store] Portable mode: ${isPortableMode()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return storeInstance
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions for common operations
|
||||||
|
export function getSettings(): AppSettings {
|
||||||
|
return getStore().get('settings', defaultSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSettings(settings: AppSettings): void {
|
||||||
|
getStore().set('settings', settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChannels(): Channel[] {
|
||||||
|
return getStore().get('channels', [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveChannels(channels: Channel[]): void {
|
||||||
|
getStore().set('channels', channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addChannel(channel: Channel): void {
|
||||||
|
const channels = getChannels()
|
||||||
|
channels.push(channel)
|
||||||
|
saveChannels(channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateChannel(channelId: string, updates: Partial<Channel>): void {
|
||||||
|
const channels = getChannels()
|
||||||
|
const index = channels.findIndex(c => c.id === channelId)
|
||||||
|
if (index !== -1) {
|
||||||
|
channels[index] = { ...channels[index], ...updates }
|
||||||
|
saveChannels(channels)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeChannel(channelId: string): void {
|
||||||
|
const channels = getChannels().filter(c => c.id !== channelId)
|
||||||
|
saveChannels(channels)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getErrorLog(): ErrorLogEntry[] {
|
||||||
|
return getStore().get('errorLog', [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addErrorLog(entry: ErrorLogEntry): void {
|
||||||
|
const log = getErrorLog()
|
||||||
|
log.unshift(entry) // Add to beginning
|
||||||
|
// Keep only last 100 entries
|
||||||
|
if (log.length > 100) {
|
||||||
|
log.length = 100
|
||||||
|
}
|
||||||
|
getStore().set('errorLog', log)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearErrorLog(): void {
|
||||||
|
getStore().set('errorLog', [])
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWindowBounds() {
|
||||||
|
return getStore().get('windowBounds')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveWindowBounds(bounds: { x?: number; y?: number; width: number; height: number }): void {
|
||||||
|
getStore().set('windowBounds', bounds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export portable mode check
|
||||||
|
export { isPortableMode }
|
||||||
|
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { HashRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { AnimatePresence } from 'framer-motion'
|
||||||
|
import Layout from './components/Layout'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import Channels from './pages/Channels'
|
||||||
|
import Monitoring from './pages/Monitoring'
|
||||||
|
import Settings from './pages/Settings'
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<HashRouter>
|
||||||
|
<Layout>
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/channels" element={<Channels />} />
|
||||||
|
<Route path="/monitoring" element={<Monitoring />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
</Routes>
|
||||||
|
</AnimatePresence>
|
||||||
|
</Layout>
|
||||||
|
</HashRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
import { ReactNode } from 'react'
|
||||||
|
import Sidebar from './Sidebar'
|
||||||
|
import TitleBar from './TitleBar'
|
||||||
|
|
||||||
|
interface LayoutProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Layout({ children }: LayoutProps) {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen flex flex-col bg-dark-900 overflow-hidden">
|
||||||
|
<TitleBar />
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<Sidebar />
|
||||||
|
<main className="flex-1 overflow-auto p-6">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Tv2,
|
||||||
|
Activity,
|
||||||
|
Settings,
|
||||||
|
Globe
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ path: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
|
{ path: '/channels', icon: Tv2, label: 'Kanallar' },
|
||||||
|
{ path: '/monitoring', icon: Activity, label: 'İzleme' },
|
||||||
|
{ path: '/settings', icon: Settings, label: 'Ayarlar' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function Sidebar() {
|
||||||
|
return (
|
||||||
|
<nav className="w-64 bg-dark-800 border-r border-dark-700 flex flex-col">
|
||||||
|
{/* Navigation Items */}
|
||||||
|
<div className="flex-1 py-4 px-3 space-y-1">
|
||||||
|
{navItems.map((item) => (
|
||||||
|
<NavLink
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-4 py-3 rounded-lg transition-all duration-200 group relative ${
|
||||||
|
isActive
|
||||||
|
? 'bg-gradient-to-r from-accent-blue/20 to-accent-purple/10 text-accent-blue'
|
||||||
|
: 'text-dark-300 hover:text-dark-100 hover:bg-dark-700'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{({ isActive }) => (
|
||||||
|
<>
|
||||||
|
{isActive && (
|
||||||
|
<motion.div
|
||||||
|
layoutId="sidebar-active"
|
||||||
|
className="absolute left-0 w-1 h-8 bg-accent-blue rounded-r-full"
|
||||||
|
initial={false}
|
||||||
|
transition={{ type: 'spring', stiffness: 300, damping: 30 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<item.icon className="w-5 h-5" />
|
||||||
|
<span className="font-medium text-sm">{item.label}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Footer */}
|
||||||
|
<div className="p-4 border-t border-dark-700">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-dark-400">
|
||||||
|
<Globe className="w-4 h-4" />
|
||||||
|
<span>Bağlantı durumu</span>
|
||||||
|
<span className="ml-auto flex items-center gap-1">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-accent-green status-online" />
|
||||||
|
Aktif
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { Minus, Square, X, Radio } from 'lucide-react'
|
||||||
|
|
||||||
|
export default function TitleBar() {
|
||||||
|
const handleMinimize = () => {
|
||||||
|
window.electron?.minimize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMaximize = () => {
|
||||||
|
window.electron?.maximize()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
window.electron?.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-10 bg-dark-800 flex items-center justify-between px-4 titlebar-drag border-b border-dark-700">
|
||||||
|
{/* App Logo & Title */}
|
||||||
|
<div className="flex items-center gap-2 titlebar-no-drag">
|
||||||
|
<div className="w-6 h-6 rounded-lg bg-gradient-to-br from-accent-blue to-accent-purple flex items-center justify-center">
|
||||||
|
<Radio className="w-4 h-4 text-white" />
|
||||||
|
</div>
|
||||||
|
<span className="font-display font-semibold text-sm text-dark-100">
|
||||||
|
IP Monitor
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-dark-400 ml-2">v1.0.0</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Window Controls */}
|
||||||
|
<div className="flex items-center gap-1 titlebar-no-drag">
|
||||||
|
<button
|
||||||
|
onClick={handleMinimize}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded hover:bg-dark-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Minus className="w-4 h-4 text-dark-300" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleMaximize}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded hover:bg-dark-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Square className="w-3 h-3 text-dark-300" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="w-8 h-8 flex items-center justify-center rounded hover:bg-accent-red transition-colors group"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4 text-dark-300 group-hover:text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,592 @@
|
||||||
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
|
import Hls from 'hls.js'
|
||||||
|
import {
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Volume2,
|
||||||
|
VolumeX,
|
||||||
|
Maximize,
|
||||||
|
Minimize,
|
||||||
|
RefreshCw,
|
||||||
|
X,
|
||||||
|
Tv2,
|
||||||
|
AlertCircle,
|
||||||
|
Bug
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
|
||||||
|
interface VideoPlayerProps {
|
||||||
|
url: string
|
||||||
|
title?: string
|
||||||
|
onClose?: () => void
|
||||||
|
autoPlay?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DebugInfo {
|
||||||
|
hlsSupported: boolean
|
||||||
|
nativeHlsSupported: boolean
|
||||||
|
url: string
|
||||||
|
videoState: string
|
||||||
|
hlsState: string
|
||||||
|
error: string | null
|
||||||
|
bufferLength: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VideoPlayer({
|
||||||
|
url,
|
||||||
|
title = 'Canlı Yayın',
|
||||||
|
onClose,
|
||||||
|
autoPlay = true
|
||||||
|
}: VideoPlayerProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const hlsRef = useRef<Hls | null>(null)
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
const [isPlaying, setIsPlaying] = useState(false)
|
||||||
|
const [isMuted, setIsMuted] = useState(false)
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showControls, setShowControls] = useState(true)
|
||||||
|
const [volume, setVolume] = useState(1)
|
||||||
|
const [showDebug, setShowDebug] = useState(false)
|
||||||
|
const [debugInfo, setDebugInfo] = useState<DebugInfo>({
|
||||||
|
hlsSupported: Hls.isSupported(),
|
||||||
|
nativeHlsSupported: false,
|
||||||
|
url: url,
|
||||||
|
videoState: 'init',
|
||||||
|
hlsState: 'none',
|
||||||
|
error: null,
|
||||||
|
bufferLength: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateDebug = useCallback((updates: Partial<DebugInfo>) => {
|
||||||
|
setDebugInfo(prev => ({ ...prev, ...updates }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
updateDebug({ url, videoState: 'loading', error: null })
|
||||||
|
|
||||||
|
// Check native HLS support
|
||||||
|
const nativeHls = video.canPlayType('application/vnd.apple.mpegurl') !== ''
|
||||||
|
updateDebug({ nativeHlsSupported: nativeHls })
|
||||||
|
|
||||||
|
// Determine stream type
|
||||||
|
const isHlsStream = url.includes('.m3u8')
|
||||||
|
const isTsStream = url.includes('.ts')
|
||||||
|
const isLiveStream = url.includes('/live/')
|
||||||
|
|
||||||
|
console.log('[VideoPlayer] Stream info:', { url, isHlsStream, isTsStream, isLiveStream, hlsSupported: Hls.isSupported(), nativeHls })
|
||||||
|
|
||||||
|
// Cleanup previous HLS instance
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy()
|
||||||
|
hlsRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((isHlsStream || isLiveStream) && Hls.isSupported()) {
|
||||||
|
// Use HLS.js for m3u8/live streams
|
||||||
|
updateDebug({ hlsState: 'initializing' })
|
||||||
|
|
||||||
|
const hls = new Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: true,
|
||||||
|
backBufferLength: 90,
|
||||||
|
maxBufferLength: 30,
|
||||||
|
maxMaxBufferLength: 60,
|
||||||
|
startLevel: -1, // Auto quality
|
||||||
|
debug: false,
|
||||||
|
xhrSetup: (xhr, urlToLoad) => {
|
||||||
|
// Add headers if needed
|
||||||
|
xhr.withCredentials = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hlsRef.current = hls
|
||||||
|
|
||||||
|
hls.on(Hls.Events.MEDIA_ATTACHED, () => {
|
||||||
|
console.log('[HLS] Media attached')
|
||||||
|
updateDebug({ hlsState: 'attached' })
|
||||||
|
hls.loadSource(url)
|
||||||
|
})
|
||||||
|
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, (_, data) => {
|
||||||
|
console.log('[HLS] Manifest parsed, levels:', data.levels.length)
|
||||||
|
updateDebug({ hlsState: 'manifest_parsed', videoState: 'ready' })
|
||||||
|
setIsLoading(false)
|
||||||
|
if (autoPlay) {
|
||||||
|
video.play().catch(e => {
|
||||||
|
console.warn('[HLS] Autoplay blocked:', e)
|
||||||
|
setIsPlaying(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hls.on(Hls.Events.MANIFEST_LOADING, () => {
|
||||||
|
updateDebug({ hlsState: 'loading_manifest' })
|
||||||
|
})
|
||||||
|
|
||||||
|
hls.on(Hls.Events.LEVEL_LOADED, (_, data) => {
|
||||||
|
console.log('[HLS] Level loaded, duration:', data.details.totalduration)
|
||||||
|
})
|
||||||
|
|
||||||
|
hls.on(Hls.Events.FRAG_LOADED, () => {
|
||||||
|
updateDebug({ videoState: 'buffering' })
|
||||||
|
})
|
||||||
|
|
||||||
|
hls.on(Hls.Events.BUFFER_APPENDED, () => {
|
||||||
|
if (video.buffered.length > 0) {
|
||||||
|
const bufferEnd = video.buffered.end(video.buffered.length - 1)
|
||||||
|
updateDebug({ bufferLength: bufferEnd - video.currentTime })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||||
|
console.error('[HLS] Error:', data.type, data.details, data)
|
||||||
|
updateDebug({ hlsState: `error_${data.type}`, error: data.details })
|
||||||
|
|
||||||
|
if (data.fatal) {
|
||||||
|
switch (data.type) {
|
||||||
|
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||||
|
setError(`Ağ hatası: ${data.details}`)
|
||||||
|
// Try to recover
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('[HLS] Attempting recovery from network error')
|
||||||
|
hls.startLoad()
|
||||||
|
}, 2000)
|
||||||
|
break
|
||||||
|
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||||
|
setError(`Medya hatası: ${data.details}`)
|
||||||
|
console.log('[HLS] Attempting recovery from media error')
|
||||||
|
hls.recoverMediaError()
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
setError(`Stream yüklenemedi: ${data.details}`)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
hls.attachMedia(video)
|
||||||
|
|
||||||
|
} else if (nativeHls && isHlsStream) {
|
||||||
|
// Native HLS support (Safari)
|
||||||
|
console.log('[VideoPlayer] Using native HLS')
|
||||||
|
updateDebug({ hlsState: 'native' })
|
||||||
|
video.src = url
|
||||||
|
|
||||||
|
video.addEventListener('loadedmetadata', () => {
|
||||||
|
setIsLoading(false)
|
||||||
|
updateDebug({ videoState: 'ready' })
|
||||||
|
if (autoPlay) video.play().catch(() => setIsPlaying(false))
|
||||||
|
})
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Direct source (ts, mp4, etc.)
|
||||||
|
console.log('[VideoPlayer] Using direct source')
|
||||||
|
updateDebug({ hlsState: 'direct' })
|
||||||
|
video.src = url
|
||||||
|
|
||||||
|
const handleCanPlay = () => {
|
||||||
|
console.log('[Video] Can play')
|
||||||
|
setIsLoading(false)
|
||||||
|
updateDebug({ videoState: 'ready' })
|
||||||
|
if (autoPlay) {
|
||||||
|
video.play().catch(e => {
|
||||||
|
console.warn('[Video] Autoplay blocked:', e)
|
||||||
|
setIsPlaying(false)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleError = (e: Event) => {
|
||||||
|
const videoError = video.error
|
||||||
|
console.error('[Video] Error:', videoError)
|
||||||
|
let errorMsg = 'Video yüklenemedi'
|
||||||
|
|
||||||
|
if (videoError) {
|
||||||
|
switch (videoError.code) {
|
||||||
|
case MediaError.MEDIA_ERR_ABORTED:
|
||||||
|
errorMsg = 'Video yüklemesi iptal edildi'
|
||||||
|
break
|
||||||
|
case MediaError.MEDIA_ERR_NETWORK:
|
||||||
|
errorMsg = 'Ağ hatası - Video indirilemedi'
|
||||||
|
break
|
||||||
|
case MediaError.MEDIA_ERR_DECODE:
|
||||||
|
errorMsg = 'Codec hatası - Video çözümlenemedi'
|
||||||
|
break
|
||||||
|
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||||
|
errorMsg = 'Format desteklenmiyor veya CORS hatası'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (videoError.message) {
|
||||||
|
errorMsg += `: ${videoError.message}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setError(errorMsg)
|
||||||
|
updateDebug({ videoState: 'error', error: errorMsg })
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
video.addEventListener('canplay', handleCanPlay)
|
||||||
|
video.addEventListener('error', handleError)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('canplay', handleCanPlay)
|
||||||
|
video.removeEventListener('error', handleError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy()
|
||||||
|
hlsRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [url, autoPlay, updateDebug])
|
||||||
|
|
||||||
|
// Video event handlers
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
const handlePlay = () => {
|
||||||
|
setIsPlaying(true)
|
||||||
|
updateDebug({ videoState: 'playing' })
|
||||||
|
}
|
||||||
|
const handlePause = () => {
|
||||||
|
setIsPlaying(false)
|
||||||
|
updateDebug({ videoState: 'paused' })
|
||||||
|
}
|
||||||
|
const handleVolumeChange = () => {
|
||||||
|
setVolume(video.volume)
|
||||||
|
setIsMuted(video.muted)
|
||||||
|
}
|
||||||
|
const handleWaiting = () => {
|
||||||
|
updateDebug({ videoState: 'buffering' })
|
||||||
|
}
|
||||||
|
|
||||||
|
video.addEventListener('play', handlePlay)
|
||||||
|
video.addEventListener('pause', handlePause)
|
||||||
|
video.addEventListener('volumechange', handleVolumeChange)
|
||||||
|
video.addEventListener('waiting', handleWaiting)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
video.removeEventListener('play', handlePlay)
|
||||||
|
video.removeEventListener('pause', handlePause)
|
||||||
|
video.removeEventListener('volumechange', handleVolumeChange)
|
||||||
|
video.removeEventListener('waiting', handleWaiting)
|
||||||
|
}
|
||||||
|
}, [updateDebug])
|
||||||
|
|
||||||
|
// Auto-hide controls
|
||||||
|
useEffect(() => {
|
||||||
|
let timeout: NodeJS.Timeout
|
||||||
|
|
||||||
|
const handleMouseMove = () => {
|
||||||
|
setShowControls(true)
|
||||||
|
clearTimeout(timeout)
|
||||||
|
timeout = setTimeout(() => setShowControls(false), 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = containerRef.current
|
||||||
|
if (container) {
|
||||||
|
container.addEventListener('mousemove', handleMouseMove)
|
||||||
|
container.addEventListener('mouseenter', handleMouseMove)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout)
|
||||||
|
if (container) {
|
||||||
|
container.removeEventListener('mousemove', handleMouseMove)
|
||||||
|
container.removeEventListener('mouseenter', handleMouseMove)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
if (isPlaying) {
|
||||||
|
video.pause()
|
||||||
|
} else {
|
||||||
|
video.play().catch(e => console.error('Play failed:', e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMute = () => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video) return
|
||||||
|
video.muted = !video.muted
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video) return
|
||||||
|
const newVolume = parseFloat(e.target.value)
|
||||||
|
video.volume = newVolume
|
||||||
|
video.muted = newVolume === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleFullscreen = () => {
|
||||||
|
const container = containerRef.current
|
||||||
|
if (!container) return
|
||||||
|
|
||||||
|
if (!document.fullscreenElement) {
|
||||||
|
container.requestFullscreen()
|
||||||
|
setIsFullscreen(true)
|
||||||
|
} else {
|
||||||
|
document.exitFullscreen()
|
||||||
|
setIsFullscreen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReload = () => {
|
||||||
|
const video = videoRef.current
|
||||||
|
if (!video) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
if (hlsRef.current) {
|
||||||
|
hlsRef.current.destroy()
|
||||||
|
|
||||||
|
const hls = new Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: true,
|
||||||
|
})
|
||||||
|
hlsRef.current = hls
|
||||||
|
hls.loadSource(url)
|
||||||
|
hls.attachMedia(video)
|
||||||
|
hls.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||||
|
setIsLoading(false)
|
||||||
|
video.play()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
video.src = url
|
||||||
|
video.load()
|
||||||
|
video.play().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
ref={containerRef}
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className="relative bg-black rounded-xl overflow-hidden group"
|
||||||
|
style={{ aspectRatio: '16/9' }}
|
||||||
|
>
|
||||||
|
{/* Video Element */}
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
playsInline
|
||||||
|
crossOrigin="anonymous"
|
||||||
|
onClick={togglePlay}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading Overlay */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isLoading && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0 bg-dark-900/80 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<RefreshCw className="w-12 h-12 text-accent-blue spinner mx-auto" />
|
||||||
|
<p className="text-dark-300 mt-3">Yayın yükleniyor...</p>
|
||||||
|
<p className="text-dark-500 text-xs mt-1">{debugInfo.hlsState}</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Error Overlay */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0 bg-dark-900/90 flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<div className="text-center max-w-md px-4">
|
||||||
|
<AlertCircle className="w-12 h-12 text-accent-red mx-auto" />
|
||||||
|
<p className="text-accent-red mt-3 font-medium">{error}</p>
|
||||||
|
<p className="text-dark-400 text-xs mt-2 break-all">{url}</p>
|
||||||
|
<div className="flex gap-2 justify-center mt-4">
|
||||||
|
<button
|
||||||
|
onClick={handleReload}
|
||||||
|
className="px-4 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Tekrar Dene
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDebug(!showDebug)}
|
||||||
|
className="px-4 py-2 bg-dark-700 text-dark-300 rounded-lg hover:bg-dark-600 transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Bug className="w-4 h-4" />
|
||||||
|
Debug
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Debug Panel */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showDebug && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, x: 20 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
exit={{ opacity: 0, x: 20 }}
|
||||||
|
className="absolute top-12 right-4 bg-dark-900/95 border border-dark-600 rounded-lg p-4 text-xs font-mono max-w-xs z-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-accent-blue font-semibold">Debug Info</span>
|
||||||
|
<button onClick={() => setShowDebug(false)} className="text-dark-400 hover:text-white">
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-dark-300">
|
||||||
|
<p><span className="text-dark-500">HLS.js:</span> {debugInfo.hlsSupported ? '✅' : '❌'}</p>
|
||||||
|
<p><span className="text-dark-500">Native HLS:</span> {debugInfo.nativeHlsSupported ? '✅' : '❌'}</p>
|
||||||
|
<p><span className="text-dark-500">State:</span> {debugInfo.hlsState}</p>
|
||||||
|
<p><span className="text-dark-500">Video:</span> {debugInfo.videoState}</p>
|
||||||
|
<p><span className="text-dark-500">Buffer:</span> {debugInfo.bufferLength.toFixed(1)}s</p>
|
||||||
|
{debugInfo.error && (
|
||||||
|
<p className="text-accent-red"><span className="text-dark-500">Error:</span> {debugInfo.error}</p>
|
||||||
|
)}
|
||||||
|
<p className="text-dark-500 break-all mt-2">URL: {debugInfo.url.substring(0, 50)}...</p>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Controls Overlay */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showControls && !isLoading && !error && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-black/40"
|
||||||
|
>
|
||||||
|
{/* Top Bar */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 p-4 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tv2 className="w-5 h-5 text-accent-red" />
|
||||||
|
<span className="text-white font-medium">{title}</span>
|
||||||
|
<span className="px-2 py-0.5 bg-accent-red text-white text-xs rounded">CANLI</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDebug(!showDebug)}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${showDebug ? 'bg-accent-blue/30 text-accent-blue' : 'bg-dark-800/50 hover:bg-dark-700 text-white'}`}
|
||||||
|
title="Debug Paneli"
|
||||||
|
>
|
||||||
|
<Bug className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 rounded-lg bg-dark-800/50 hover:bg-dark-700 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5 text-white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center Play Button */}
|
||||||
|
{!isPlaying && (
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-20 h-20 rounded-full bg-accent-blue/80 hover:bg-accent-blue flex items-center justify-center transition-colors"
|
||||||
|
>
|
||||||
|
<Play className="w-10 h-10 text-white ml-1" fill="white" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bottom Controls */}
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Play/Pause */}
|
||||||
|
<button
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="p-2 rounded-lg bg-dark-800/50 hover:bg-dark-700 transition-colors"
|
||||||
|
>
|
||||||
|
{isPlaying ? (
|
||||||
|
<Pause className="w-5 h-5 text-white" />
|
||||||
|
) : (
|
||||||
|
<Play className="w-5 h-5 text-white" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Volume */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={toggleMute}
|
||||||
|
className="p-2 rounded-lg bg-dark-800/50 hover:bg-dark-700 transition-colors"
|
||||||
|
>
|
||||||
|
{isMuted || volume === 0 ? (
|
||||||
|
<VolumeX className="w-5 h-5 text-white" />
|
||||||
|
) : (
|
||||||
|
<Volume2 className="w-5 h-5 text-white" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.1"
|
||||||
|
value={isMuted ? 0 : volume}
|
||||||
|
onChange={handleVolumeChange}
|
||||||
|
className="w-20 h-1 bg-dark-600 rounded-full appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:rounded-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reload */}
|
||||||
|
<button
|
||||||
|
onClick={handleReload}
|
||||||
|
className="p-2 rounded-lg bg-dark-800/50 hover:bg-dark-700 transition-colors"
|
||||||
|
title="Yeniden Yükle"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-5 h-5 text-white" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fullscreen */}
|
||||||
|
<button
|
||||||
|
onClick={toggleFullscreen}
|
||||||
|
className="p-2 rounded-lg bg-dark-800/50 hover:bg-dark-700 transition-colors"
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<Minimize className="w-5 h-5 text-white" />
|
||||||
|
) : (
|
||||||
|
<Maximize className="w-5 h-5 text-white" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './styles/globals.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
@ -0,0 +1,501 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
Tv2,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Filter,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Loader2,
|
||||||
|
Trash2,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
Link2,
|
||||||
|
FolderOpen,
|
||||||
|
Play
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useStore } from '../store/useStore'
|
||||||
|
import VideoPlayer from '../components/VideoPlayer'
|
||||||
|
import type { Channel, ChannelStatus } from '@shared/types'
|
||||||
|
|
||||||
|
// Status Badge Component
|
||||||
|
function StatusBadge({ status }: { status: ChannelStatus }) {
|
||||||
|
const config = {
|
||||||
|
online: { icon: CheckCircle2, text: 'Çevrimiçi', color: 'text-accent-green bg-accent-green/10' },
|
||||||
|
offline: { icon: XCircle, text: 'Çevrimdışı', color: 'text-accent-red bg-accent-red/10' },
|
||||||
|
error: { icon: AlertCircle, text: 'Hata', color: 'text-accent-orange bg-accent-orange/10' },
|
||||||
|
checking: { icon: Loader2, text: 'Kontrol', color: 'text-accent-blue bg-accent-blue/10' },
|
||||||
|
unknown: { icon: AlertCircle, text: 'Bilinmiyor', color: 'text-dark-400 bg-dark-600' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const { icon: Icon, text, color } = config[status]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium ${color}`}>
|
||||||
|
<Icon className={`w-3 h-3 ${status === 'checking' ? 'spinner' : ''}`} />
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel Row Component
|
||||||
|
function ChannelRow({
|
||||||
|
channel,
|
||||||
|
onToggleMonitor,
|
||||||
|
onWatch
|
||||||
|
}: {
|
||||||
|
channel: Channel
|
||||||
|
onToggleMonitor: (id: string, monitored: boolean) => void
|
||||||
|
onWatch: (channel: Channel) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="bg-dark-700/50 border border-dark-600 rounded-lg p-4 hover:bg-dark-700 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Logo/Icon */}
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-dark-600 flex items-center justify-center overflow-hidden shrink-0">
|
||||||
|
{channel.logo ? (
|
||||||
|
<img src={channel.logo} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<Tv2 className="w-5 h-5 text-dark-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="font-medium text-dark-100 truncate">{channel.name}</h3>
|
||||||
|
{channel.group && (
|
||||||
|
<span className="text-xs bg-dark-600 text-dark-300 px-2 py-0.5 rounded">
|
||||||
|
{channel.group}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-dark-400 truncate mt-1 font-mono">{channel.url}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<StatusBadge status={channel.status} />
|
||||||
|
|
||||||
|
{/* Latency */}
|
||||||
|
{channel.latency && (
|
||||||
|
<span className="text-xs text-dark-400 font-mono w-16 text-right">
|
||||||
|
{channel.latency}ms
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Watch Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => onWatch(channel)}
|
||||||
|
className="p-2 rounded-lg bg-accent-green/20 text-accent-green hover:bg-accent-green/30 transition-colors"
|
||||||
|
title="Canlı İzle"
|
||||||
|
>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Monitor Toggle */}
|
||||||
|
<button
|
||||||
|
onClick={() => onToggleMonitor(channel.id, !channel.isMonitored)}
|
||||||
|
className={`p-2 rounded-lg transition-colors ${
|
||||||
|
channel.isMonitored
|
||||||
|
? 'bg-accent-blue/20 text-accent-blue hover:bg-accent-blue/30'
|
||||||
|
: 'bg-dark-600 text-dark-400 hover:bg-dark-500'
|
||||||
|
}`}
|
||||||
|
title={channel.isMonitored ? 'İzlemeyi durdur' : 'İzlemeye başla'}
|
||||||
|
>
|
||||||
|
{channel.isMonitored ? <Eye className="w-4 h-4" /> : <EyeOff className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{channel.lastError && (
|
||||||
|
<div className="mt-3 p-2 bg-accent-red/10 border border-accent-red/20 rounded-md">
|
||||||
|
<p className="text-xs text-accent-red">{channel.lastError}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add Playlist/URL Modal
|
||||||
|
function AddPlaylistModal({
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
onAdd,
|
||||||
|
onAddSingle
|
||||||
|
}: {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onAdd: (url: string) => void
|
||||||
|
onAddSingle: (name: string, url: string) => void
|
||||||
|
}) {
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [channelName, setChannelName] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [mode, setMode] = useState<'playlist' | 'single'>('playlist')
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!url.trim()) return
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
if (mode === 'playlist') {
|
||||||
|
await onAdd(url.trim())
|
||||||
|
} else {
|
||||||
|
await onAddSingle(channelName.trim() || 'Yeni Kanal', url.trim())
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
setUrl('')
|
||||||
|
setChannelName('')
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.95, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.95, opacity: 0 }}
|
||||||
|
className="bg-dark-800 border border-dark-600 rounded-xl p-6 w-full max-w-lg"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-dark-100 mb-4 flex items-center gap-2">
|
||||||
|
<Link2 className="w-5 h-5 text-accent-blue" />
|
||||||
|
Kanal/Playlist Ekle
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{/* Mode Tabs */}
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('playlist')}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
mode === 'playlist'
|
||||||
|
? 'bg-accent-blue text-white'
|
||||||
|
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
M3U8 Playlist
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMode('single')}
|
||||||
|
className={`flex-1 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
mode === 'single'
|
||||||
|
? 'bg-accent-blue text-white'
|
||||||
|
: 'bg-dark-700 text-dark-300 hover:bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Tek URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{mode === 'single' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-dark-300 mb-2">Kanal Adı</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={channelName}
|
||||||
|
onChange={(e) => setChannelName(e.target.value)}
|
||||||
|
placeholder="Örn: TRT 1 HD"
|
||||||
|
className="w-full px-4 py-3 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-dark-300 mb-2">
|
||||||
|
{mode === 'playlist' ? 'Playlist URL' : 'Stream URL'}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder={mode === 'playlist'
|
||||||
|
? "https://example.com/playlist.m3u8"
|
||||||
|
: "http://example.com:8080/live/stream.ts"
|
||||||
|
}
|
||||||
|
className="w-full px-4 py-3 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-blue"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-dark-300 hover:text-dark-100 transition-colors"
|
||||||
|
>
|
||||||
|
İptal
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!url.trim() || loading}
|
||||||
|
className="px-4 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors disabled:opacity-50 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{loading && <Loader2 className="w-4 h-4 spinner" />}
|
||||||
|
{mode === 'playlist' ? 'Playlist Yükle' : 'Kanal Ekle'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Channels() {
|
||||||
|
const { channels, setChannels, channelsLoading, setChannelsLoading } = useStore()
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [filterStatus, setFilterStatus] = useState<ChannelStatus | 'all'>('all')
|
||||||
|
const [showMonitoredOnly, setShowMonitoredOnly] = useState(false)
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [watchingChannel, setWatchingChannel] = useState<Channel | null>(null)
|
||||||
|
|
||||||
|
// Load channels on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadChannels = async () => {
|
||||||
|
if (window.electron) {
|
||||||
|
setChannelsLoading(true)
|
||||||
|
try {
|
||||||
|
const loadedChannels = await window.electron.getChannels()
|
||||||
|
setChannels(loadedChannels)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load channels:', error)
|
||||||
|
} finally {
|
||||||
|
setChannelsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadChannels()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleAddPlaylist = async (url: string) => {
|
||||||
|
if (window.electron) {
|
||||||
|
setChannelsLoading(true)
|
||||||
|
try {
|
||||||
|
const newChannels = await window.electron.parseM3u8(url)
|
||||||
|
setChannels([...channels, ...newChannels])
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to parse playlist:', error)
|
||||||
|
} finally {
|
||||||
|
setChannelsLoading(false)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Demo data
|
||||||
|
const demoChannels: Channel[] = [
|
||||||
|
{ id: '1', name: 'TRT 1 HD', url: 'https://tv-trt1.medya.trt.com.tr/master.m3u8', group: 'Ulusal', isMonitored: true, status: 'online', latency: 45 },
|
||||||
|
{ id: '2', name: 'Show TV', url: 'https://ciner-live.daioncdn.net/showtv/showtv.m3u8', group: 'Ulusal', isMonitored: true, status: 'online', latency: 67 },
|
||||||
|
{ id: '3', name: 'ATV', url: 'https://trkvz-live.ercdn.net/atvhd/atvhd.m3u8', group: 'Ulusal', isMonitored: false, status: 'unknown' },
|
||||||
|
{ id: '4', name: 'TV8', url: 'https://tv8-live.ercdn.net/tv8/tv8.m3u8', group: 'Ulusal', isMonitored: true, status: 'error', lastError: 'ERR_CONNECTION_RESET' },
|
||||||
|
{ id: '5', name: 'CNN Türk', url: 'https://cnn.com/live.m3u8', group: 'Haber', isMonitored: true, status: 'offline' },
|
||||||
|
]
|
||||||
|
setChannels([...channels, ...demoChannels])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAddSingleChannel = async (name: string, url: string) => {
|
||||||
|
const newChannel: Channel = {
|
||||||
|
id: Date.now().toString(),
|
||||||
|
name,
|
||||||
|
url,
|
||||||
|
group: 'Manuel',
|
||||||
|
isMonitored: true,
|
||||||
|
status: 'unknown'
|
||||||
|
}
|
||||||
|
setChannels([...channels, newChannel])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleMonitor = async (channelId: string, monitored: boolean) => {
|
||||||
|
if (window.electron) {
|
||||||
|
await window.electron.setChannelMonitored(channelId, monitored)
|
||||||
|
}
|
||||||
|
setChannels(
|
||||||
|
channels.map((c) =>
|
||||||
|
c.id === channelId ? { ...c, isMonitored: monitored } : c
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWatch = (channel: Channel) => {
|
||||||
|
setWatchingChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter channels
|
||||||
|
const filteredChannels = channels.filter((channel) => {
|
||||||
|
const matchesSearch = channel.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
channel.url.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
const matchesStatus = filterStatus === 'all' || channel.status === filterStatus
|
||||||
|
const matchesMonitored = !showMonitoredOnly || channel.isMonitored
|
||||||
|
return matchesSearch && matchesStatus && matchesMonitored
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group channels by group
|
||||||
|
const groupedChannels = filteredChannels.reduce((acc, channel) => {
|
||||||
|
const group = channel.group || 'Diğer'
|
||||||
|
if (!acc[group]) acc[group] = []
|
||||||
|
acc[group].push(channel)
|
||||||
|
return acc
|
||||||
|
}, {} as Record<string, Channel[]>)
|
||||||
|
|
||||||
|
const monitoredCount = channels.filter(c => c.isMonitored).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-display font-bold text-dark-100">Kanal Yönetimi</h1>
|
||||||
|
<p className="text-dark-400 text-sm mt-1">
|
||||||
|
{channels.length} kanal · {monitoredCount} izleniyor
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Playlist Ekle
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-dark-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Kanal ara..."
|
||||||
|
className="w-full pl-10 pr-4 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={filterStatus}
|
||||||
|
onChange={(e) => setFilterStatus(e.target.value as ChannelStatus | 'all')}
|
||||||
|
className="px-4 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 focus:outline-none focus:border-accent-blue"
|
||||||
|
>
|
||||||
|
<option value="all">Tüm Durumlar</option>
|
||||||
|
<option value="online">Çevrimiçi</option>
|
||||||
|
<option value="offline">Çevrimdışı</option>
|
||||||
|
<option value="error">Hatalı</option>
|
||||||
|
<option value="unknown">Bilinmiyor</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setShowMonitoredOnly(!showMonitoredOnly)}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg border transition-colors ${
|
||||||
|
showMonitoredOnly
|
||||||
|
? 'bg-accent-blue/20 border-accent-blue/30 text-accent-blue'
|
||||||
|
: 'bg-dark-700 border-dark-600 text-dark-300 hover:text-dark-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
Sadece İzlenen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel List */}
|
||||||
|
{channelsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<Loader2 className="w-8 h-8 text-accent-blue spinner" />
|
||||||
|
</div>
|
||||||
|
) : filteredChannels.length === 0 ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex flex-col items-center justify-center py-20 text-dark-400"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-16 h-16 mb-4 text-dark-600" />
|
||||||
|
<p className="text-lg">Henüz kanal eklenmemiş</p>
|
||||||
|
<p className="text-sm mt-2">M3U8 playlist URL'i ekleyerek başlayın</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{Object.entries(groupedChannels).map(([group, groupChannels]) => (
|
||||||
|
<div key={group}>
|
||||||
|
<h3 className="text-sm font-medium text-dark-400 mb-3 flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full bg-accent-purple" />
|
||||||
|
{group}
|
||||||
|
<span className="text-dark-500">({groupChannels.length})</span>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{groupChannels.map((channel) => (
|
||||||
|
<ChannelRow
|
||||||
|
key={channel.id}
|
||||||
|
channel={channel}
|
||||||
|
onToggleMonitor={handleToggleMonitor}
|
||||||
|
onWatch={handleWatch}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Playlist Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{isModalOpen && (
|
||||||
|
<AddPlaylistModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onAdd={handleAddPlaylist}
|
||||||
|
onAddSingle={handleAddSingleChannel}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Video Player Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{watchingChannel && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-8"
|
||||||
|
onClick={() => setWatchingChannel(null)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
className="w-full max-w-5xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<VideoPlayer
|
||||||
|
url={watchingChannel.url}
|
||||||
|
title={watchingChannel.name}
|
||||||
|
onClose={() => setWatchingChannel(null)}
|
||||||
|
autoPlay={true}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,294 @@
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
Globe,
|
||||||
|
MapPin,
|
||||||
|
Wifi,
|
||||||
|
Tv2,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle2,
|
||||||
|
RefreshCw,
|
||||||
|
Activity,
|
||||||
|
Clock,
|
||||||
|
Bell
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useStore } from '../store/useStore'
|
||||||
|
import type { IpInfo, ErrorLogEntry } from '@shared/types'
|
||||||
|
|
||||||
|
// Status Card Component
|
||||||
|
function StatCard({
|
||||||
|
icon: Icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
subValue,
|
||||||
|
color = 'blue',
|
||||||
|
delay = 0
|
||||||
|
}: {
|
||||||
|
icon: React.ElementType
|
||||||
|
label: string
|
||||||
|
value: string | number
|
||||||
|
subValue?: string
|
||||||
|
color?: 'blue' | 'green' | 'red' | 'yellow' | 'purple'
|
||||||
|
delay?: number
|
||||||
|
}) {
|
||||||
|
const colorClasses = {
|
||||||
|
blue: 'from-accent-blue/20 to-accent-blue/5 border-accent-blue/30 text-accent-blue',
|
||||||
|
green: 'from-accent-green/20 to-accent-green/5 border-accent-green/30 text-accent-green',
|
||||||
|
red: 'from-accent-red/20 to-accent-red/5 border-accent-red/30 text-accent-red',
|
||||||
|
yellow: 'from-accent-yellow/20 to-accent-yellow/5 border-accent-yellow/30 text-accent-yellow',
|
||||||
|
purple: 'from-accent-purple/20 to-accent-purple/5 border-accent-purple/30 text-accent-purple',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay, duration: 0.3 }}
|
||||||
|
className={`bg-gradient-to-br ${colorClasses[color]} border rounded-xl p-5 card-hover`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-400 text-xs uppercase tracking-wider mb-1">{label}</p>
|
||||||
|
<p className="text-2xl font-bold text-dark-100">{value}</p>
|
||||||
|
{subValue && <p className="text-xs text-dark-400 mt-1">{subValue}</p>}
|
||||||
|
</div>
|
||||||
|
<div className={`p-2 rounded-lg bg-dark-800/50`}>
|
||||||
|
<Icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IP Info Card Component
|
||||||
|
function IpInfoCard({ ipInfo, loading, onRefresh }: {
|
||||||
|
ipInfo: IpInfo | null
|
||||||
|
loading: boolean
|
||||||
|
onRefresh: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
className="bg-dark-800 border border-dark-700 rounded-xl p-6 col-span-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-dark-100 flex items-center gap-2">
|
||||||
|
<Globe className="w-5 h-5 text-accent-blue" />
|
||||||
|
IP Bilgileri
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 text-dark-300 ${loading ? 'spinner' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<div className="w-8 h-8 border-2 border-accent-blue border-t-transparent rounded-full spinner" />
|
||||||
|
</div>
|
||||||
|
) : ipInfo ? (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-dark-700/50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 text-dark-400 text-xs mb-2">
|
||||||
|
<Wifi className="w-4 h-4" />
|
||||||
|
IP Adresi
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-mono font-bold text-accent-cyan">{ipInfo.ip}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-dark-700/50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-2 text-dark-400 text-xs mb-2">
|
||||||
|
<MapPin className="w-4 h-4" />
|
||||||
|
Konum
|
||||||
|
</div>
|
||||||
|
<p className="text-xl font-semibold text-dark-100">
|
||||||
|
{ipInfo.country}
|
||||||
|
<span className="ml-2 text-sm bg-dark-600 px-2 py-0.5 rounded">
|
||||||
|
{ipInfo.countryCode}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{ipInfo.city && (
|
||||||
|
<div className="bg-dark-700/50 rounded-lg p-4">
|
||||||
|
<div className="text-dark-400 text-xs mb-2">Şehir</div>
|
||||||
|
<p className="text-lg font-semibold text-dark-100">{ipInfo.city}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{ipInfo.isp && (
|
||||||
|
<div className="bg-dark-700/50 rounded-lg p-4">
|
||||||
|
<div className="text-dark-400 text-xs mb-2">ISP</div>
|
||||||
|
<p className="text-lg font-semibold text-dark-100 truncate">{ipInfo.isp}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-dark-400">
|
||||||
|
IP bilgisi alınamadı
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent Errors Component
|
||||||
|
function RecentErrors({ errors }: { errors: ErrorLogEntry[] }) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ delay: 0.3 }}
|
||||||
|
className="bg-dark-800 border border-dark-700 rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-dark-100 flex items-center gap-2 mb-4">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-accent-red" />
|
||||||
|
Son Hatalar
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{errors.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8 text-dark-400">
|
||||||
|
<CheckCircle2 className="w-12 h-12 mb-3 text-accent-green" />
|
||||||
|
<p>Hata bulunamadı</p>
|
||||||
|
<p className="text-xs mt-1">Tüm sistemler normal çalışıyor</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-64 overflow-auto">
|
||||||
|
{errors.slice(0, 5).map((error, index) => (
|
||||||
|
<motion.div
|
||||||
|
key={error.id}
|
||||||
|
initial={{ opacity: 0, x: -10 }}
|
||||||
|
animate={{ opacity: 1, x: 0 }}
|
||||||
|
transition={{ delay: index * 0.05 }}
|
||||||
|
className="bg-dark-700/50 border border-dark-600 rounded-lg p-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-medium text-dark-100 truncate">{error.channelName}</p>
|
||||||
|
<p className="text-xs text-accent-red mt-1">{error.errorType}</p>
|
||||||
|
<p className="text-xs text-dark-400 truncate mt-1">{error.errorMessage}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 ml-3">
|
||||||
|
{error.notified && (
|
||||||
|
<Bell className="w-3 h-3 text-accent-green" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-dark-500">
|
||||||
|
{new Date(error.timestamp).toLocaleTimeString('tr-TR')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const {
|
||||||
|
ipInfo,
|
||||||
|
ipLoading,
|
||||||
|
setIpInfo,
|
||||||
|
setIpLoading,
|
||||||
|
channels,
|
||||||
|
errorLog,
|
||||||
|
isMonitoring
|
||||||
|
} = useStore()
|
||||||
|
|
||||||
|
const [lastUpdate, setLastUpdate] = useState<Date>(new Date())
|
||||||
|
|
||||||
|
const fetchIpInfo = async () => {
|
||||||
|
setIpLoading(true)
|
||||||
|
try {
|
||||||
|
if (window.electron) {
|
||||||
|
const info = await window.electron.getIpInfo()
|
||||||
|
setIpInfo(info)
|
||||||
|
} else {
|
||||||
|
// Demo data for development
|
||||||
|
setIpInfo({
|
||||||
|
ip: '185.123.45.67',
|
||||||
|
country: 'Turkey',
|
||||||
|
countryCode: 'TR',
|
||||||
|
city: 'Istanbul',
|
||||||
|
isp: 'Turk Telekom'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setLastUpdate(new Date())
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch IP info:', error)
|
||||||
|
} finally {
|
||||||
|
setIpLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchIpInfo()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const monitoredChannels = channels.filter(c => c.isMonitored)
|
||||||
|
const onlineChannels = channels.filter(c => c.status === 'online')
|
||||||
|
const errorChannels = channels.filter(c => c.status === 'error' || c.status === 'offline')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-display font-bold text-dark-100">Dashboard</h1>
|
||||||
|
<p className="text-dark-400 text-sm mt-1">
|
||||||
|
Sistem durumu ve özet bilgiler
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-xs text-dark-400">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
Son güncelleme: {lastUpdate.toLocaleTimeString('tr-TR')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<StatCard
|
||||||
|
icon={Tv2}
|
||||||
|
label="Toplam Kanal"
|
||||||
|
value={channels.length}
|
||||||
|
subValue={`${monitoredChannels.length} izleniyor`}
|
||||||
|
color="blue"
|
||||||
|
delay={0}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={CheckCircle2}
|
||||||
|
label="Çevrimiçi"
|
||||||
|
value={onlineChannels.length}
|
||||||
|
subValue="Aktif yayınlar"
|
||||||
|
color="green"
|
||||||
|
delay={0.1}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={AlertTriangle}
|
||||||
|
label="Hatalı"
|
||||||
|
value={errorChannels.length}
|
||||||
|
subValue={`${errorLog.length} log kaydı`}
|
||||||
|
color="red"
|
||||||
|
delay={0.2}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
icon={Activity}
|
||||||
|
label="İzleme Durumu"
|
||||||
|
value={isMonitoring ? 'Aktif' : 'Pasif'}
|
||||||
|
subValue={isMonitoring ? 'Otomatik kontrol' : 'Beklemede'}
|
||||||
|
color={isMonitoring ? 'green' : 'yellow'}
|
||||||
|
delay={0.3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content Grid */}
|
||||||
|
<div className="grid grid-cols-3 gap-6">
|
||||||
|
<IpInfoCard ipInfo={ipInfo} loading={ipLoading} onRefresh={fetchIpInfo} />
|
||||||
|
<RecentErrors errors={errorLog} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,405 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
RefreshCw,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
Clock,
|
||||||
|
Wifi,
|
||||||
|
Bell,
|
||||||
|
BellOff,
|
||||||
|
Loader2,
|
||||||
|
Signal,
|
||||||
|
TrendingUp,
|
||||||
|
Tv2,
|
||||||
|
X
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useStore } from '../store/useStore'
|
||||||
|
import VideoPlayer from '../components/VideoPlayer'
|
||||||
|
import type { Channel, MonitoringResult } from '@shared/types'
|
||||||
|
|
||||||
|
// Live Status Indicator
|
||||||
|
function LiveIndicator({ isLive }: { isLive: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={`flex items-center gap-2 px-3 py-1 rounded-full text-xs font-medium ${
|
||||||
|
isLive ? 'bg-accent-green/20 text-accent-green' : 'bg-dark-600 text-dark-400'
|
||||||
|
}`}>
|
||||||
|
<span className={`w-2 h-2 rounded-full ${isLive ? 'bg-accent-green status-online' : 'bg-dark-500'}`} />
|
||||||
|
{isLive ? 'Canlı İzleme' : 'Durduruldu'}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Channel Status Card
|
||||||
|
function ChannelStatusCard({
|
||||||
|
channel,
|
||||||
|
result,
|
||||||
|
onManualNotify,
|
||||||
|
onWatch
|
||||||
|
}: {
|
||||||
|
channel: Channel
|
||||||
|
result?: MonitoringResult
|
||||||
|
onManualNotify: (channel: Channel) => void
|
||||||
|
onWatch: (channel: Channel) => void
|
||||||
|
}) {
|
||||||
|
const statusConfig = {
|
||||||
|
online: { icon: CheckCircle2, color: 'text-accent-green', bg: 'bg-accent-green/10 border-accent-green/20' },
|
||||||
|
offline: { icon: XCircle, color: 'text-accent-red', bg: 'bg-accent-red/10 border-accent-red/20' },
|
||||||
|
error: { icon: AlertTriangle, color: 'text-accent-orange', bg: 'bg-accent-orange/10 border-accent-orange/20' },
|
||||||
|
checking: { icon: Loader2, color: 'text-accent-blue', bg: 'bg-accent-blue/10 border-accent-blue/20' },
|
||||||
|
unknown: { icon: Signal, color: 'text-dark-400', bg: 'bg-dark-700 border-dark-600' },
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = result?.status || channel.status
|
||||||
|
const config = statusConfig[status]
|
||||||
|
const Icon = config.icon
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
layout
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
className={`border rounded-xl p-4 ${config.bg} card-hover`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg bg-dark-800/50`}>
|
||||||
|
<Icon className={`w-5 h-5 ${config.color} ${status === 'checking' ? 'spinner' : ''}`} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-dark-100">{channel.name}</h3>
|
||||||
|
<p className="text-xs text-dark-400 mt-0.5">{channel.group || 'Genel'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Watch Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => onWatch(channel)}
|
||||||
|
className="p-2 rounded-lg bg-accent-blue/20 hover:bg-accent-blue/30 transition-colors"
|
||||||
|
title="Canlı İzle"
|
||||||
|
>
|
||||||
|
<Tv2 className="w-4 h-4 text-accent-blue" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Manual Notify Button */}
|
||||||
|
{(status === 'error' || status === 'offline') && (
|
||||||
|
<button
|
||||||
|
onClick={() => onManualNotify(channel)}
|
||||||
|
className="p-2 rounded-lg bg-dark-800/50 hover:bg-dark-700 transition-colors"
|
||||||
|
title="Manuel Bildirim Gönder"
|
||||||
|
>
|
||||||
|
<Bell className="w-4 h-4 text-accent-yellow" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
|
<div className="bg-dark-800/30 rounded-lg p-2">
|
||||||
|
<div className="text-xs text-dark-400 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Gecikme
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-mono text-dark-100 mt-1">
|
||||||
|
{result?.latency ? `${result.latency}ms` : channel.latency ? `${channel.latency}ms` : '---'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-dark-800/30 rounded-lg p-2">
|
||||||
|
<div className="text-xs text-dark-400 flex items-center gap-1">
|
||||||
|
<Wifi className="w-3 h-3" />
|
||||||
|
HTTP
|
||||||
|
</div>
|
||||||
|
<p className={`text-sm font-mono mt-1 ${
|
||||||
|
result?.httpStatus === 200 ? 'text-accent-green' :
|
||||||
|
result?.httpStatus ? 'text-accent-red' : 'text-dark-400'
|
||||||
|
}`}>
|
||||||
|
{result?.httpStatus || '---'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{result?.error && (
|
||||||
|
<div className="mt-3 p-2 bg-dark-800/50 rounded-lg border border-dark-600">
|
||||||
|
<p className="text-xs text-accent-red font-mono">{result.errorType}</p>
|
||||||
|
<p className="text-xs text-dark-400 mt-1 truncate">{result.error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Last Check Time */}
|
||||||
|
<div className="mt-3 flex items-center justify-between text-xs text-dark-500">
|
||||||
|
<span>Son kontrol:</span>
|
||||||
|
<span>
|
||||||
|
{result?.timestamp
|
||||||
|
? new Date(result.timestamp).toLocaleTimeString('tr-TR')
|
||||||
|
: channel.lastCheck
|
||||||
|
? new Date(channel.lastCheck).toLocaleTimeString('tr-TR')
|
||||||
|
: '---'
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats Overview
|
||||||
|
function StatsOverview({
|
||||||
|
total,
|
||||||
|
online,
|
||||||
|
offline,
|
||||||
|
errors
|
||||||
|
}: {
|
||||||
|
total: number
|
||||||
|
online: number
|
||||||
|
offline: number
|
||||||
|
errors: number
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-dark-800 border border-dark-700 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 text-dark-400 text-xs">
|
||||||
|
<Signal className="w-4 h-4" />
|
||||||
|
Toplam İzlenen
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-dark-100 mt-2">{total}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-accent-green/10 border border-accent-green/20 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 text-accent-green text-xs">
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
Çevrimiçi
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-accent-green mt-2">{online}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-accent-red/10 border border-accent-red/20 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 text-accent-red text-xs">
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
Çevrimdışı
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-accent-red mt-2">{offline}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-accent-orange/10 border border-accent-orange/20 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-2 text-accent-orange text-xs">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
Hata
|
||||||
|
</div>
|
||||||
|
<p className="text-2xl font-bold text-accent-orange mt-2">{errors}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Monitoring() {
|
||||||
|
const {
|
||||||
|
channels,
|
||||||
|
isMonitoring,
|
||||||
|
setIsMonitoring,
|
||||||
|
monitoringResults,
|
||||||
|
updateMonitoringResult,
|
||||||
|
settings
|
||||||
|
} = useStore()
|
||||||
|
|
||||||
|
const [lastRefresh, setLastRefresh] = useState<Date>(new Date())
|
||||||
|
const [watchingChannel, setWatchingChannel] = useState<Channel | null>(null)
|
||||||
|
|
||||||
|
const monitoredChannels = channels.filter(c => c.isMonitored)
|
||||||
|
|
||||||
|
// Subscribe to monitoring updates
|
||||||
|
useEffect(() => {
|
||||||
|
if (window.electron) {
|
||||||
|
const unsubscribe = window.electron.onMonitoringUpdate((result) => {
|
||||||
|
updateMonitoringResult(result)
|
||||||
|
setLastRefresh(new Date())
|
||||||
|
})
|
||||||
|
return () => unsubscribe()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleToggleMonitoring = async () => {
|
||||||
|
if (window.electron) {
|
||||||
|
if (isMonitoring) {
|
||||||
|
await window.electron.stopMonitoring()
|
||||||
|
} else {
|
||||||
|
await window.electron.startMonitoring()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setIsMonitoring(!isMonitoring)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManualRefresh = async () => {
|
||||||
|
// Trigger immediate check of all channels
|
||||||
|
if (window.electron && isMonitoring) {
|
||||||
|
// This would trigger a manual check cycle
|
||||||
|
}
|
||||||
|
setLastRefresh(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleManualNotify = async (channel: Channel) => {
|
||||||
|
if (window.electron) {
|
||||||
|
const result = monitoringResults.get(channel.id)
|
||||||
|
await window.electron.sendNotification(
|
||||||
|
`🔴 ${channel.name} - Yayın Hatası`,
|
||||||
|
result?.error || channel.lastError || 'Yayın erişilemez durumda'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleWatch = (channel: Channel) => {
|
||||||
|
setWatchingChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate stats
|
||||||
|
const stats = {
|
||||||
|
total: monitoredChannels.length,
|
||||||
|
online: monitoredChannels.filter(c => {
|
||||||
|
const result = monitoringResults.get(c.id)
|
||||||
|
return (result?.status || c.status) === 'online'
|
||||||
|
}).length,
|
||||||
|
offline: monitoredChannels.filter(c => {
|
||||||
|
const result = monitoringResults.get(c.id)
|
||||||
|
return (result?.status || c.status) === 'offline'
|
||||||
|
}).length,
|
||||||
|
errors: monitoredChannels.filter(c => {
|
||||||
|
const result = monitoringResults.get(c.id)
|
||||||
|
return (result?.status || c.status) === 'error'
|
||||||
|
}).length,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-display font-bold text-dark-100 flex items-center gap-3">
|
||||||
|
<Activity className="w-7 h-7 text-accent-purple" />
|
||||||
|
Canlı İzleme
|
||||||
|
</h1>
|
||||||
|
<p className="text-dark-400 text-sm mt-1">
|
||||||
|
Son güncelleme: {lastRefresh.toLocaleTimeString('tr-TR')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<LiveIndicator isLive={isMonitoring} />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleManualRefresh}
|
||||||
|
disabled={!isMonitoring}
|
||||||
|
className="p-2 rounded-lg bg-dark-700 hover:bg-dark-600 disabled:opacity-50 transition-colors"
|
||||||
|
title="Yenile"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-5 h-5 text-dark-300" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleToggleMonitoring}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 rounded-lg transition-colors ${
|
||||||
|
isMonitoring
|
||||||
|
? 'bg-accent-red/20 text-accent-red hover:bg-accent-red/30'
|
||||||
|
: 'bg-accent-green text-white hover:bg-accent-green/80'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isMonitoring ? (
|
||||||
|
<>
|
||||||
|
<Pause className="w-4 h-4" />
|
||||||
|
Durdur
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Play className="w-4 h-4" />
|
||||||
|
Başlat
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Overview */}
|
||||||
|
<StatsOverview {...stats} />
|
||||||
|
|
||||||
|
{/* Auto Notification Status */}
|
||||||
|
<div className="flex items-center justify-between bg-dark-800 border border-dark-700 rounded-xl p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{settings?.notifications.autoNotify ? (
|
||||||
|
<Bell className="w-5 h-5 text-accent-green" />
|
||||||
|
) : (
|
||||||
|
<BellOff className="w-5 h-5 text-dark-400" />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-dark-100">Otomatik Bildirimler</p>
|
||||||
|
<p className="text-xs text-dark-400 mt-0.5">
|
||||||
|
{settings?.notifications.autoNotify
|
||||||
|
? 'Hata durumunda otomatik bildirim gönderilecek'
|
||||||
|
: 'Otomatik bildirimler kapalı - Ayarlardan etkinleştirin'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<TrendingUp className={`w-5 h-5 ${settings?.notifications.autoNotify ? 'text-accent-green' : 'text-dark-500'}`} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Monitored Channels Grid */}
|
||||||
|
{monitoredChannels.length === 0 ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
className="flex flex-col items-center justify-center py-20 text-dark-400"
|
||||||
|
>
|
||||||
|
<Activity className="w-16 h-16 mb-4 text-dark-600" />
|
||||||
|
<p className="text-lg">Henüz izlenen kanal yok</p>
|
||||||
|
<p className="text-sm mt-2">Kanallar sayfasından izlemek istediğiniz kanalları seçin</p>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<AnimatePresence mode="popLayout">
|
||||||
|
{monitoredChannels.map((channel) => (
|
||||||
|
<ChannelStatusCard
|
||||||
|
key={channel.id}
|
||||||
|
channel={channel}
|
||||||
|
result={monitoringResults.get(channel.id)}
|
||||||
|
onManualNotify={handleManualNotify}
|
||||||
|
onWatch={handleWatch}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Video Player Modal */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{watchingChannel && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center z-50 p-8"
|
||||||
|
onClick={() => setWatchingChannel(null)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
className="w-full max-w-5xl"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<VideoPlayer
|
||||||
|
url={watchingChannel.url}
|
||||||
|
title={watchingChannel.name}
|
||||||
|
onClose={() => setWatchingChannel(null)}
|
||||||
|
autoPlay={true}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,518 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { motion } from 'framer-motion'
|
||||||
|
import {
|
||||||
|
Settings as SettingsIcon,
|
||||||
|
Bell,
|
||||||
|
Mail,
|
||||||
|
MessageCircle,
|
||||||
|
Monitor,
|
||||||
|
Server,
|
||||||
|
Save,
|
||||||
|
TestTube,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
Loader2,
|
||||||
|
Clock,
|
||||||
|
Shield,
|
||||||
|
HardDrive,
|
||||||
|
Info
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useStore } from '../store/useStore'
|
||||||
|
import type { AppSettings, NotificationType } from '@shared/types'
|
||||||
|
|
||||||
|
// Section Component
|
||||||
|
function SettingsSection({
|
||||||
|
title,
|
||||||
|
icon: Icon,
|
||||||
|
children
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
icon: React.ElementType
|
||||||
|
children: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className="bg-dark-800 border border-dark-700 rounded-xl p-6"
|
||||||
|
>
|
||||||
|
<h2 className="text-lg font-semibold text-dark-100 flex items-center gap-2 mb-4">
|
||||||
|
<Icon className="w-5 h-5 text-accent-purple" />
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle Switch
|
||||||
|
function Toggle({
|
||||||
|
enabled,
|
||||||
|
onChange
|
||||||
|
}: {
|
||||||
|
enabled: boolean
|
||||||
|
onChange: (value: boolean) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={() => onChange(!enabled)}
|
||||||
|
className={`relative w-12 h-6 rounded-full transition-colors ${
|
||||||
|
enabled ? 'bg-accent-blue' : 'bg-dark-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-1 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||||
|
enabled ? 'left-7' : 'left-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input Field
|
||||||
|
function InputField({
|
||||||
|
label,
|
||||||
|
type = 'text',
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
type?: string
|
||||||
|
value: string | number
|
||||||
|
onChange: (value: string) => void
|
||||||
|
placeholder?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm text-dark-300 mb-2">{label}</label>
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className="w-full px-4 py-2 bg-dark-700 border border-dark-600 rounded-lg text-dark-100 placeholder-dark-500 focus:outline-none focus:border-accent-blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Button
|
||||||
|
function TestButton({
|
||||||
|
type,
|
||||||
|
onTest
|
||||||
|
}: {
|
||||||
|
type: NotificationType
|
||||||
|
onTest: (type: NotificationType) => Promise<void>
|
||||||
|
}) {
|
||||||
|
const [testing, setTesting] = useState(false)
|
||||||
|
const [result, setResult] = useState<'success' | 'error' | null>(null)
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
setTesting(true)
|
||||||
|
setResult(null)
|
||||||
|
try {
|
||||||
|
await onTest(type)
|
||||||
|
setResult('success')
|
||||||
|
} catch {
|
||||||
|
setResult('error')
|
||||||
|
} finally {
|
||||||
|
setTesting(false)
|
||||||
|
setTimeout(() => setResult(null), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={testing}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 bg-dark-600 text-dark-300 rounded-lg hover:bg-dark-500 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{testing ? (
|
||||||
|
<Loader2 className="w-4 h-4 spinner" />
|
||||||
|
) : result === 'success' ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4 text-accent-green" />
|
||||||
|
) : result === 'error' ? (
|
||||||
|
<XCircle className="w-4 h-4 text-accent-red" />
|
||||||
|
) : (
|
||||||
|
<TestTube className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
Test
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const { settings, setSettings } = useStore()
|
||||||
|
const [localSettings, setLocalSettings] = useState<AppSettings | null>(settings)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [appInfo, setAppInfo] = useState<{ version: string; isPortable: boolean; dataPath: string } | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalSettings(settings)
|
||||||
|
}, [settings])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAppInfo = async () => {
|
||||||
|
if (window.electron?.getAppInfo) {
|
||||||
|
const info = await window.electron.getAppInfo()
|
||||||
|
setAppInfo(info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loadAppInfo()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!localSettings) return
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
if (window.electron) {
|
||||||
|
await window.electron.saveSettings(localSettings)
|
||||||
|
}
|
||||||
|
setSettings(localSettings)
|
||||||
|
setSaved(true)
|
||||||
|
setTimeout(() => setSaved(false), 2000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save settings:', error)
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTestNotification = async (type: NotificationType) => {
|
||||||
|
if (window.electron) {
|
||||||
|
await window.electron.testNotification(type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSettings = (path: string, value: unknown) => {
|
||||||
|
if (!localSettings) return
|
||||||
|
|
||||||
|
const keys = path.split('.')
|
||||||
|
const newSettings = { ...localSettings }
|
||||||
|
let current: Record<string, unknown> = newSettings as Record<string, unknown>
|
||||||
|
|
||||||
|
for (let i = 0; i < keys.length - 1; i++) {
|
||||||
|
current[keys[i]] = { ...(current[keys[i]] as Record<string, unknown>) }
|
||||||
|
current = current[keys[i]] as Record<string, unknown>
|
||||||
|
}
|
||||||
|
current[keys[keys.length - 1]] = value
|
||||||
|
|
||||||
|
setLocalSettings(newSettings as AppSettings)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!localSettings) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="w-8 h-8 text-accent-blue spinner" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-display font-bold text-dark-100 flex items-center gap-3">
|
||||||
|
<SettingsIcon className="w-7 h-7 text-accent-purple" />
|
||||||
|
Ayarlar
|
||||||
|
</h1>
|
||||||
|
<p className="text-dark-400 text-sm mt-1">
|
||||||
|
Uygulama ve bildirim ayarlarını yapılandırın
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-accent-blue text-white rounded-lg hover:bg-accent-blue/80 transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="w-4 h-4 spinner" />
|
||||||
|
) : saved ? (
|
||||||
|
<CheckCircle2 className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
{saved ? 'Kaydedildi!' : 'Kaydet'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-6">
|
||||||
|
{/* General Settings */}
|
||||||
|
<SettingsSection title="Genel Ayarlar" icon={Monitor}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-100">Başlangıçta minimize</p>
|
||||||
|
<p className="text-xs text-dark-400">Uygulama açıldığında küçültülmüş başla</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
enabled={localSettings.general.startMinimized}
|
||||||
|
onChange={(v) => updateSettings('general.startMinimized', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-100">Tray'e küçült</p>
|
||||||
|
<p className="text-xs text-dark-400">Kapatma yerine system tray'e taşı</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
enabled={localSettings.general.minimizeToTray}
|
||||||
|
onChange={(v) => updateSettings('general.minimizeToTray', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Monitoring Settings */}
|
||||||
|
<SettingsSection title="İzleme Ayarları" icon={Clock}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<InputField
|
||||||
|
label="Ping Aralığı (saniye)"
|
||||||
|
type="number"
|
||||||
|
value={localSettings.monitoring.pingInterval}
|
||||||
|
onChange={(v) => updateSettings('monitoring.pingInterval', parseInt(v) || 30)}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Stream Kontrol Aralığı (saniye)"
|
||||||
|
type="number"
|
||||||
|
value={localSettings.monitoring.streamCheckInterval}
|
||||||
|
onChange={(v) => updateSettings('monitoring.streamCheckInterval', parseInt(v) || 60)}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Zaman Aşımı (ms)"
|
||||||
|
type="number"
|
||||||
|
value={localSettings.monitoring.timeout}
|
||||||
|
onChange={(v) => updateSettings('monitoring.timeout', parseInt(v) || 5000)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Windows Notifications */}
|
||||||
|
<SettingsSection title="Windows Bildirimleri" icon={Bell}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-100">Etkin</p>
|
||||||
|
<p className="text-xs text-dark-400">Windows toast bildirimleri</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<TestButton type="windows" onTest={handleTestNotification} />
|
||||||
|
<Toggle
|
||||||
|
enabled={localSettings.notifications.windows.enabled}
|
||||||
|
onChange={(v) => updateSettings('notifications.windows.enabled', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Telegram Settings */}
|
||||||
|
<SettingsSection title="Telegram Bildirimleri" icon={MessageCircle}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-100">Etkin</p>
|
||||||
|
<p className="text-xs text-dark-400">Telegram bot bildirimleri</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<TestButton type="telegram" onTest={handleTestNotification} />
|
||||||
|
<Toggle
|
||||||
|
enabled={localSettings.notifications.telegram.enabled}
|
||||||
|
onChange={(v) => updateSettings('notifications.telegram.enabled', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<InputField
|
||||||
|
label="Bot Token"
|
||||||
|
value={localSettings.notifications.telegram.botToken}
|
||||||
|
onChange={(v) => updateSettings('notifications.telegram.botToken', v)}
|
||||||
|
placeholder="123456:ABC-DEF..."
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Chat ID"
|
||||||
|
value={localSettings.notifications.telegram.chatId}
|
||||||
|
onChange={(v) => updateSettings('notifications.telegram.chatId', v)}
|
||||||
|
placeholder="-1001234567890"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Email Settings */}
|
||||||
|
<SettingsSection title="Email Bildirimleri" icon={Mail}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-100">Etkin</p>
|
||||||
|
<p className="text-xs text-dark-400">SMTP email bildirimleri</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<TestButton type="email" onTest={handleTestNotification} />
|
||||||
|
<Toggle
|
||||||
|
enabled={localSettings.notifications.email.enabled}
|
||||||
|
onChange={(v) => updateSettings('notifications.email.enabled', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<InputField
|
||||||
|
label="SMTP Sunucu"
|
||||||
|
value={localSettings.notifications.email.smtpHost}
|
||||||
|
onChange={(v) => updateSettings('notifications.email.smtpHost', v)}
|
||||||
|
placeholder="smtp.gmail.com"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Port"
|
||||||
|
type="number"
|
||||||
|
value={localSettings.notifications.email.smtpPort}
|
||||||
|
onChange={(v) => updateSettings('notifications.email.smtpPort', parseInt(v) || 587)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<InputField
|
||||||
|
label="Kullanıcı"
|
||||||
|
value={localSettings.notifications.email.smtpUser}
|
||||||
|
onChange={(v) => updateSettings('notifications.email.smtpUser', v)}
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Şifre"
|
||||||
|
type="password"
|
||||||
|
value={localSettings.notifications.email.smtpPass}
|
||||||
|
onChange={(v) => updateSettings('notifications.email.smtpPass', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<InputField
|
||||||
|
label="Alıcı Email"
|
||||||
|
value={localSettings.notifications.email.toEmail}
|
||||||
|
onChange={(v) => updateSettings('notifications.email.toEmail', v)}
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Panel Integration */}
|
||||||
|
<SettingsSection title="Panel Entegrasyonu" icon={Server}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-100">Etkin</p>
|
||||||
|
<p className="text-xs text-dark-400">Monitor API entegrasyonu</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
enabled={localSettings.panel.enabled}
|
||||||
|
onChange={(v) => updateSettings('panel.enabled', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<InputField
|
||||||
|
label="API URL"
|
||||||
|
value={localSettings.panel.apiUrl}
|
||||||
|
onChange={(v) => updateSettings('panel.apiUrl', v)}
|
||||||
|
placeholder="https://panel.example.com/attacker_detection"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Monitor ID"
|
||||||
|
value={localSettings.panel.monitorId}
|
||||||
|
onChange={(v) => updateSettings('panel.monitorId', v)}
|
||||||
|
placeholder="monitor-1"
|
||||||
|
/>
|
||||||
|
<InputField
|
||||||
|
label="Auth Token"
|
||||||
|
type="password"
|
||||||
|
value={localSettings.panel.authToken}
|
||||||
|
onChange={(v) => updateSettings('panel.authToken', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Notification Rules */}
|
||||||
|
<SettingsSection title="Bildirim Kuralları" icon={Shield}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-100">Otomatik Bildirim</p>
|
||||||
|
<p className="text-xs text-dark-400">Hata anında otomatik bildir</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
enabled={localSettings.notifications.autoNotify}
|
||||||
|
onChange={(v) => updateSettings('notifications.autoNotify', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-100">Ping Hatası</p>
|
||||||
|
<p className="text-xs text-dark-400">Ping başarısız olduğunda</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
enabled={localSettings.notifications.notifyOnPingFail}
|
||||||
|
onChange={(v) => updateSettings('notifications.notifyOnPingFail', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-100">404 Hatası</p>
|
||||||
|
<p className="text-xs text-dark-400">HTTP 404 alındığında</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
enabled={localSettings.notifications.notifyOn404}
|
||||||
|
onChange={(v) => updateSettings('notifications.notifyOn404', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-100">Bağlantı Hatası</p>
|
||||||
|
<p className="text-xs text-dark-400">ERR_CONNRESET gibi hatalar</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
enabled={localSettings.notifications.notifyOnConnReset}
|
||||||
|
onChange={(v) => updateSettings('notifications.notifyOnConnReset', v)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* App Info */}
|
||||||
|
<SettingsSection title="Uygulama Bilgisi" icon={Info}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-100">Versiyon</p>
|
||||||
|
<p className="text-xs text-dark-400">IP Monitor</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-accent-blue font-mono">v{appInfo?.version || '1.0.0'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<HardDrive className="w-4 h-4 text-dark-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-dark-100">Çalışma Modu</p>
|
||||||
|
<p className="text-xs text-dark-400">Ayarların saklandığı konum</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||||
|
appInfo?.isPortable
|
||||||
|
? 'bg-accent-green/20 text-accent-green'
|
||||||
|
: 'bg-accent-blue/20 text-accent-blue'
|
||||||
|
}`}>
|
||||||
|
{appInfo?.isPortable ? 'Portable' : 'Normal'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{appInfo?.dataPath && (
|
||||||
|
<div className="bg-dark-700/50 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-dark-400 mb-1">Veri Konumu:</p>
|
||||||
|
<p className="text-xs text-dark-300 font-mono break-all">{appInfo.dataPath}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-dark-500 mt-2">
|
||||||
|
<p>💡 Portable mod için exe yanına <code className="bg-dark-700 px-1 rounded">portable.txt</code> dosyası oluşturun veya <code className="bg-dark-700 px-1 rounded">data</code> klasörü açın.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SettingsSection>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,133 @@
|
||||||
|
import { create } from 'zustand'
|
||||||
|
import type { Channel, IpInfo, ErrorLogEntry, AppSettings, MonitoringResult } from '@shared/types'
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
// IP Info
|
||||||
|
ipInfo: IpInfo | null
|
||||||
|
ipLoading: boolean
|
||||||
|
setIpInfo: (info: IpInfo | null) => void
|
||||||
|
setIpLoading: (loading: boolean) => void
|
||||||
|
|
||||||
|
// Channels
|
||||||
|
channels: Channel[]
|
||||||
|
channelsLoading: boolean
|
||||||
|
setChannels: (channels: Channel[]) => void
|
||||||
|
updateChannel: (channel: Channel) => void
|
||||||
|
setChannelsLoading: (loading: boolean) => void
|
||||||
|
|
||||||
|
// Monitoring
|
||||||
|
isMonitoring: boolean
|
||||||
|
monitoringResults: Map<string, MonitoringResult>
|
||||||
|
setIsMonitoring: (running: boolean) => void
|
||||||
|
updateMonitoringResult: (result: MonitoringResult) => void
|
||||||
|
clearMonitoringResults: () => void
|
||||||
|
|
||||||
|
// Error Log
|
||||||
|
errorLog: ErrorLogEntry[]
|
||||||
|
addErrorLog: (entry: ErrorLogEntry) => void
|
||||||
|
setErrorLog: (log: ErrorLogEntry[]) => void
|
||||||
|
clearErrorLog: () => void
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
settings: AppSettings | null
|
||||||
|
setSettings: (settings: AppSettings) => void
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
sidebarCollapsed: boolean
|
||||||
|
toggleSidebar: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultSettings: AppSettings = {
|
||||||
|
general: {
|
||||||
|
startMinimized: false,
|
||||||
|
minimizeToTray: true,
|
||||||
|
autoStart: false,
|
||||||
|
language: 'tr',
|
||||||
|
},
|
||||||
|
monitoring: {
|
||||||
|
pingInterval: 30,
|
||||||
|
streamCheckInterval: 60,
|
||||||
|
timeout: 5000,
|
||||||
|
retryCount: 2,
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
enabled: true,
|
||||||
|
windows: { enabled: true },
|
||||||
|
telegram: {
|
||||||
|
enabled: false,
|
||||||
|
botToken: '',
|
||||||
|
chatId: '',
|
||||||
|
},
|
||||||
|
email: {
|
||||||
|
enabled: false,
|
||||||
|
smtpHost: '',
|
||||||
|
smtpPort: 587,
|
||||||
|
smtpUser: '',
|
||||||
|
smtpPass: '',
|
||||||
|
fromEmail: '',
|
||||||
|
toEmail: '',
|
||||||
|
},
|
||||||
|
autoNotify: true,
|
||||||
|
notifyOnPingFail: true,
|
||||||
|
notifyOnStreamError: true,
|
||||||
|
notifyOn404: true,
|
||||||
|
notifyOnConnReset: true,
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
apiUrl: '',
|
||||||
|
authToken: '',
|
||||||
|
monitorId: '',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<AppState>((set) => ({
|
||||||
|
// IP Info
|
||||||
|
ipInfo: null,
|
||||||
|
ipLoading: false,
|
||||||
|
setIpInfo: (info) => set({ ipInfo: info }),
|
||||||
|
setIpLoading: (loading) => set({ ipLoading: loading }),
|
||||||
|
|
||||||
|
// Channels
|
||||||
|
channels: [],
|
||||||
|
channelsLoading: false,
|
||||||
|
setChannels: (channels) => set({ channels }),
|
||||||
|
updateChannel: (channel) =>
|
||||||
|
set((state) => ({
|
||||||
|
channels: state.channels.map((c) =>
|
||||||
|
c.id === channel.id ? channel : c
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
setChannelsLoading: (loading) => set({ channelsLoading: loading }),
|
||||||
|
|
||||||
|
// Monitoring
|
||||||
|
isMonitoring: false,
|
||||||
|
monitoringResults: new Map(),
|
||||||
|
setIsMonitoring: (running) => set({ isMonitoring: running }),
|
||||||
|
updateMonitoringResult: (result) =>
|
||||||
|
set((state) => {
|
||||||
|
const newResults = new Map(state.monitoringResults)
|
||||||
|
newResults.set(result.channelId, result)
|
||||||
|
return { monitoringResults: newResults }
|
||||||
|
}),
|
||||||
|
clearMonitoringResults: () => set({ monitoringResults: new Map() }),
|
||||||
|
|
||||||
|
// Error Log
|
||||||
|
errorLog: [],
|
||||||
|
addErrorLog: (entry) =>
|
||||||
|
set((state) => ({
|
||||||
|
errorLog: [entry, ...state.errorLog].slice(0, 100), // Keep last 100
|
||||||
|
})),
|
||||||
|
setErrorLog: (log) => set({ errorLog: log }),
|
||||||
|
clearErrorLog: () => set({ errorLog: [] }),
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
settings: defaultSettings,
|
||||||
|
setSettings: (settings) => set({ settings }),
|
||||||
|
|
||||||
|
// UI State
|
||||||
|
sidebarCollapsed: false,
|
||||||
|
toggleSidebar: () =>
|
||||||
|
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
|
||||||
|
}))
|
||||||
|
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-bg-primary: #0f0f12;
|
||||||
|
--color-bg-secondary: #18181b;
|
||||||
|
--color-bg-tertiary: #1e1e22;
|
||||||
|
--color-text-primary: #e4e4e7;
|
||||||
|
--color-text-secondary: #a1a1aa;
|
||||||
|
--color-accent: #61afef;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
background-color: var(--color-bg-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--color-bg-secondary);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #3f3f46;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #52525b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Title bar drag region */
|
||||||
|
.titlebar-drag {
|
||||||
|
-webkit-app-region: drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titlebar-no-drag {
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Glassmorphism effect */
|
||||||
|
.glass {
|
||||||
|
background: rgba(30, 30, 34, 0.8);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status dot animation */
|
||||||
|
@keyframes pulse-online {
|
||||||
|
0%, 100% { opacity: 1; transform: scale(1); }
|
||||||
|
50% { opacity: 0.7; transform: scale(1.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-online {
|
||||||
|
animation: pulse-online 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gradient text */
|
||||||
|
.gradient-text {
|
||||||
|
background: linear-gradient(135deg, #61afef 0%, #c678dd 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card hover effect */
|
||||||
|
.card-hover {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input focus styles */
|
||||||
|
input:focus, textarea:focus, select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(97, 175, 239, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Button active state */
|
||||||
|
button:active:not(:disabled) {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner */
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade in animation */
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-fade-in {
|
||||||
|
animation: fadeIn 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Slide in from left */
|
||||||
|
@keyframes slideInLeft {
|
||||||
|
from { opacity: 0; transform: translateX(-20px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-in {
|
||||||
|
animation: slideInLeft 0.3s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
export interface ElectronAPI {
|
||||||
|
// Window controls
|
||||||
|
minimize: () => void
|
||||||
|
maximize: () => void
|
||||||
|
close: () => void
|
||||||
|
|
||||||
|
// IP Service
|
||||||
|
getIpInfo: () => Promise<import('@shared/types').IpInfo>
|
||||||
|
|
||||||
|
// Channel Management
|
||||||
|
parseM3u8: (url: string) => Promise<import('@shared/types').Channel[]>
|
||||||
|
getChannels: () => Promise<import('@shared/types').Channel[]>
|
||||||
|
updateChannel: (channel: import('@shared/types').Channel) => Promise<void>
|
||||||
|
setChannelMonitored: (channelId: string, monitored: boolean) => Promise<void>
|
||||||
|
|
||||||
|
// Monitoring
|
||||||
|
startMonitoring: () => Promise<void>
|
||||||
|
stopMonitoring: () => Promise<void>
|
||||||
|
getMonitoringStatus: () => Promise<{ isRunning: boolean }>
|
||||||
|
onMonitoringUpdate: (callback: (result: import('@shared/types').MonitoringResult) => void) => () => void
|
||||||
|
|
||||||
|
// Ping
|
||||||
|
pingHost: (host: string) => Promise<import('@shared/types').PingResult>
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
sendNotification: (title: string, body: string, type?: import('@shared/types').NotificationType) => Promise<void>
|
||||||
|
testNotification: (type: import('@shared/types').NotificationType) => Promise<{ success: boolean; error?: string }>
|
||||||
|
|
||||||
|
// Panel API
|
||||||
|
panelRegister: (config: import('@shared/types').PanelConfig) => Promise<{ success: boolean; authToken?: string }>
|
||||||
|
panelGetIps: () => Promise<import('@shared/types').PanelIpPool>
|
||||||
|
panelSendVote: (vote: import('@shared/types').BurnVote) => Promise<{ success: boolean }>
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
getSettings: () => Promise<import('@shared/types').AppSettings>
|
||||||
|
saveSettings: (settings: import('@shared/types').AppSettings) => Promise<void>
|
||||||
|
|
||||||
|
// Error Log
|
||||||
|
getErrorLog: () => Promise<import('@shared/types').ErrorLogEntry[]>
|
||||||
|
clearErrorLog: () => Promise<void>
|
||||||
|
|
||||||
|
// App Info
|
||||||
|
getAppInfo: () => Promise<{ version: string; isPortable: boolean; dataPath: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
electron: ElectronAPI
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,173 @@
|
||||||
|
// Channel Types
|
||||||
|
export interface Channel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
group?: string;
|
||||||
|
logo?: string;
|
||||||
|
isMonitored: boolean;
|
||||||
|
status: ChannelStatus;
|
||||||
|
lastCheck?: number;
|
||||||
|
lastError?: string;
|
||||||
|
latency?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ChannelStatus = 'online' | 'offline' | 'error' | 'unknown' | 'checking';
|
||||||
|
|
||||||
|
// IP Info Types
|
||||||
|
export interface IpInfo {
|
||||||
|
ip: string;
|
||||||
|
country: string;
|
||||||
|
countryCode: string;
|
||||||
|
city?: string;
|
||||||
|
isp?: string;
|
||||||
|
timezone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitoring Types
|
||||||
|
export interface MonitoringResult {
|
||||||
|
channelId: string;
|
||||||
|
timestamp: number;
|
||||||
|
status: ChannelStatus;
|
||||||
|
latency?: number;
|
||||||
|
httpStatus?: number;
|
||||||
|
error?: string;
|
||||||
|
errorType?: 'TIMEOUT' | 'CONNRESET' | 'CONNREFUSED' | 'HTTP_ERROR' | 'PARSE_ERROR' | 'UNKNOWN';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PingResult {
|
||||||
|
host: string;
|
||||||
|
alive: boolean;
|
||||||
|
time?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notification Types
|
||||||
|
export type NotificationType = 'windows' | 'telegram' | 'email';
|
||||||
|
|
||||||
|
export interface NotificationSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
windows: {
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
telegram: {
|
||||||
|
enabled: boolean;
|
||||||
|
botToken: string;
|
||||||
|
chatId: string;
|
||||||
|
};
|
||||||
|
email: {
|
||||||
|
enabled: boolean;
|
||||||
|
smtpHost: string;
|
||||||
|
smtpPort: number;
|
||||||
|
smtpUser: string;
|
||||||
|
smtpPass: string;
|
||||||
|
fromEmail: string;
|
||||||
|
toEmail: string;
|
||||||
|
};
|
||||||
|
autoNotify: boolean;
|
||||||
|
notifyOnPingFail: boolean;
|
||||||
|
notifyOnStreamError: boolean;
|
||||||
|
notifyOn404: boolean;
|
||||||
|
notifyOnConnReset: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panel API Types (from MONITOR_API_GUIDE)
|
||||||
|
export interface PanelConfig {
|
||||||
|
apiUrl: string;
|
||||||
|
authToken: string;
|
||||||
|
monitorId: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelIpInfo {
|
||||||
|
ip_address: string;
|
||||||
|
server_id: string;
|
||||||
|
check_priority: 'high' | 'normal' | 'low';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PanelIpPool {
|
||||||
|
active: PanelIpInfo[];
|
||||||
|
honeypot: PanelIpInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BurnVote {
|
||||||
|
ip_address: string;
|
||||||
|
vote: 'burn' | 'ok' | 'abstain';
|
||||||
|
reason: string;
|
||||||
|
evidence: {
|
||||||
|
check_type: string;
|
||||||
|
port?: number;
|
||||||
|
response_time_ms?: number;
|
||||||
|
error_message?: string;
|
||||||
|
timestamp: number;
|
||||||
|
monitor_id: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// App Settings
|
||||||
|
export interface AppSettings {
|
||||||
|
general: {
|
||||||
|
startMinimized: boolean;
|
||||||
|
minimizeToTray: boolean;
|
||||||
|
autoStart: boolean;
|
||||||
|
language: 'tr' | 'en';
|
||||||
|
};
|
||||||
|
monitoring: {
|
||||||
|
pingInterval: number; // seconds
|
||||||
|
streamCheckInterval: number; // seconds
|
||||||
|
timeout: number; // ms
|
||||||
|
retryCount: number;
|
||||||
|
};
|
||||||
|
notifications: NotificationSettings;
|
||||||
|
panel: PanelConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
// IPC Channel Names
|
||||||
|
export const IPC_CHANNELS = {
|
||||||
|
// IP Service
|
||||||
|
GET_IP_INFO: 'ip:get-info',
|
||||||
|
|
||||||
|
// Channel Management
|
||||||
|
PARSE_M3U8: 'channel:parse-m3u8',
|
||||||
|
GET_CHANNELS: 'channel:get-all',
|
||||||
|
UPDATE_CHANNEL: 'channel:update',
|
||||||
|
SET_MONITORED: 'channel:set-monitored',
|
||||||
|
|
||||||
|
// Monitoring
|
||||||
|
START_MONITORING: 'monitor:start',
|
||||||
|
STOP_MONITORING: 'monitor:stop',
|
||||||
|
GET_MONITORING_STATUS: 'monitor:status',
|
||||||
|
MONITORING_UPDATE: 'monitor:update',
|
||||||
|
|
||||||
|
// Ping
|
||||||
|
PING_HOST: 'ping:host',
|
||||||
|
|
||||||
|
// Notifications
|
||||||
|
SEND_NOTIFICATION: 'notify:send',
|
||||||
|
TEST_NOTIFICATION: 'notify:test',
|
||||||
|
|
||||||
|
// Panel API
|
||||||
|
PANEL_REGISTER: 'panel:register',
|
||||||
|
PANEL_GET_IPS: 'panel:get-ips',
|
||||||
|
PANEL_SEND_VOTE: 'panel:send-vote',
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
GET_SETTINGS: 'settings:get',
|
||||||
|
SAVE_SETTINGS: 'settings:save',
|
||||||
|
|
||||||
|
// Window
|
||||||
|
MINIMIZE_TO_TRAY: 'window:minimize-tray',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Error Log Entry
|
||||||
|
export interface ErrorLogEntry {
|
||||||
|
id: string;
|
||||||
|
timestamp: number;
|
||||||
|
channelName: string;
|
||||||
|
channelId: string;
|
||||||
|
errorType: string;
|
||||||
|
errorMessage: string;
|
||||||
|
httpStatus?: number;
|
||||||
|
notified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./index.html",
|
||||||
|
"./src/renderer/**/*.{js,ts,jsx,tsx}",
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
// Custom dark theme inspired by One Dark
|
||||||
|
dark: {
|
||||||
|
50: '#e4e4e7',
|
||||||
|
100: '#c8c8cd',
|
||||||
|
200: '#a1a1aa',
|
||||||
|
300: '#71717a',
|
||||||
|
400: '#52525b',
|
||||||
|
500: '#3f3f46',
|
||||||
|
600: '#27272a',
|
||||||
|
700: '#1e1e22',
|
||||||
|
800: '#18181b',
|
||||||
|
900: '#0f0f12',
|
||||||
|
950: '#09090b',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
blue: '#61afef',
|
||||||
|
purple: '#c678dd',
|
||||||
|
green: '#98c379',
|
||||||
|
red: '#e06c75',
|
||||||
|
orange: '#d19a66',
|
||||||
|
cyan: '#56b6c2',
|
||||||
|
yellow: '#e5c07b',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||||
|
display: ['Space Grotesk', 'system-ui', 'sans-serif'],
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||||
|
'glow': 'glow 2s ease-in-out infinite alternate',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
glow: {
|
||||||
|
'0%': { boxShadow: '0 0 5px rgba(97, 175, 239, 0.5)' },
|
||||||
|
'100%': { boxShadow: '0 0 20px rgba(97, 175, 239, 0.8)' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/renderer/*"],
|
||||||
|
"@shared/*": ["src/shared/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/renderer/**/*", "src/shared/**/*", "src/vite-env.d.ts"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "CommonJS",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "dist/main",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@shared/*": ["src/shared/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/main/**/*", "src/shared/**/*"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: './',
|
||||||
|
root: '.',
|
||||||
|
build: {
|
||||||
|
outDir: 'dist/renderer',
|
||||||
|
emptyOutDir: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src/renderer'),
|
||||||
|
'@shared': path.resolve(__dirname, './src/shared'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
Loading…
Reference in New Issue