Refactor: Transition to Integrated Management Platform (DB Separation) v0.1.0

This commit is contained in:
choibk 2026-01-23 11:43:58 +09:00
parent f5f7086fbf
commit a771fc0561
8 changed files with 240 additions and 201 deletions

View File

@ -1,7 +1,7 @@
{
"name": "temp_app",
"private": true,
"version": "0.0.0",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
@ -42,4 +42,4 @@
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}
}

View File

@ -0,0 +1,38 @@
const mysql = require('mysql2/promise');
require('dotenv').config(); // Load .env from current directory
async function addColumn() {
process.env.DB_HOST = process.env.DB_HOST || 'localhost';
process.env.DB_USER = process.env.DB_USER || 'root';
process.env.DB_PASSWORD = process.env.DB_PASSWORD || 'password'; // Fallback or assume env is loaded
process.env.DB_NAME = process.env.DB_NAME || 'smartasset_db';
console.log(`Connecting to database: ${process.env.DB_NAME} at ${process.env.DB_HOST}`);
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
port: process.env.DB_PORT || 3306
});
try {
console.log("Checking if 'is_active' column exists...");
const [rows] = await connection.execute("SHOW COLUMNS FROM camera_settings LIKE 'is_active'");
if (rows.length === 0) {
console.log("Adding 'is_active' column...");
await connection.execute("ALTER TABLE camera_settings ADD COLUMN is_active BOOLEAN DEFAULT TRUE");
console.log("Column 'is_active' added successfully!");
} else {
console.log("Column 'is_active' already exists.");
}
} catch (error) {
console.error("Error updating database:", error);
} finally {
await connection.end();
}
}
addColumn();

View File

@ -357,12 +357,17 @@ app.post('/api/upload', upload.single('image'), (req, res) => {
app.use('/api', require('./modules/asset/routes'));
// Serve Frontend Static Files (Production/Deployment)
app.use(express.static(path.join(__dirname, '../dist')));
// Strict Sibling Structure: Expects 'dist' to be a sibling of 'server'
// This matches the local development environment structure.
const distPath = path.join(__dirname, '../dist');
console.log(`Serving static files from: ${distPath}`);
app.use(express.static(distPath));
// The "catchall" handler: for any request that doesn't
// match one above, send back React's index.html file.
app.get(/(.*)/, (req, res) => {
res.sendFile(path.join(__dirname, '../dist/index.html'));
res.sendFile(path.join(distPath, 'index.html'));
});
const server = app.listen(PORT, () => {

53
server/migrate_db.js Normal file
View File

@ -0,0 +1,53 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
const SOURCE_DB = 'smart_asset_db';
const TARGET_DBS = ['sokuree_platform_dev', 'sokuree_platform_prod'];
async function migrate() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
port: process.env.DB_PORT || 3306
});
try {
console.log(`Connected to MySQL at ${process.env.DB_HOST}`);
for (const targetDb of TARGET_DBS) {
console.log(`\nProcessing Target DB: ${targetDb}`);
// 1. Create Database
console.log(`Creating database ${targetDb} if not exists...`);
await connection.query(`CREATE DATABASE IF NOT EXISTS ${targetDb}`);
// 2. Get Tables from Source
const [tables] = await connection.query(`SHOW TABLES FROM ${SOURCE_DB}`);
const tableNames = tables.map(row => Object.values(row)[0]);
for (const table of tableNames) {
console.log(` Migrating table: ${table}...`);
// 3. Create Table (Structure)
// Drop if exists to ensure clean state or handle updates?
// Let's drop for now to ensure exact clone.
await connection.query(`DROP TABLE IF EXISTS ${targetDb}.${table}`);
await connection.query(`CREATE TABLE ${targetDb}.${table} LIKE ${SOURCE_DB}.${table}`);
// 4. Copy Data
await connection.query(`INSERT INTO ${targetDb}.${table} SELECT * FROM ${SOURCE_DB}.${table}`);
console.log(` -> Data copied.`);
}
}
console.log('\nMigration completed successfully!');
} catch (error) {
console.error('Migration failed:', error);
} finally {
await connection.end();
}
}
migrate();

View File

@ -91,6 +91,27 @@ router.put('/:id', hasRole('admin'), async (req, res) => {
}
});
// Toggle Stream Status (Admin only)
router.patch('/:id/status', hasRole('admin'), async (req, res) => {
const { is_active } = req.body;
try {
const [result] = await db.query('UPDATE camera_settings SET is_active = ? WHERE id = ?', [is_active, req.params.id]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
const streamRelay = req.app.get('streamRelay');
if (streamRelay) {
// If disabled, stop stream. If enabled, reset (which stops and allows reconnect)
console.log(`Stream status changed for camera ${req.params.id} to ${is_active}`);
streamRelay.resetStream(req.params.id);
}
res.json({ message: `Stream ${is_active ? 'enabled' : 'disabled'}` });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// Delete camera (Admin only)
router.delete('/:id', hasRole('admin'), async (req, res) => {
try {

View File

@ -1,55 +1,11 @@
const WebSocket = require('ws');
const ffmpeg = require('fluent-ffmpeg');
const ffmpegPath = require('ffmpeg-static');
const staticFfmpegBinary = require('ffmpeg-static'); // Renamed for clarity
const db = require('../../db');
const fs = require('fs');
// Determine FFmpeg path: Prefer system install on Synology, fallback to static
// Determine FFmpeg path: Prioritize Community packages (RTSP support) > System (limited) > Static
let systemFfmpegPath = ffmpegPath;
// Potential paths for fully-featured FFmpeg (SynoCommunity, etc.)
// User 'find /usr' showed it's NOT in /usr/local/bin, so we must look in package dirs.
const priorityPaths = [
// SynoCommunity or Package Center standard paths
'/var/packages/ffmpeg/target/bin/ffmpeg',
'/var/packages/ffmpeg7/target/bin/ffmpeg',
'/var/packages/ffmpeg6/target/bin/ffmpeg',
// Direct volume paths (common on Synology)
'/volume1/@appstore/ffmpeg/bin/ffmpeg',
'/volume1/@appstore/ffmpeg7/bin/ffmpeg',
'/volume1/@appstore/ffmpeg6/bin/ffmpeg',
// Standard Linux paths
'/usr/local/bin/ffmpeg',
'/usr/bin/ffmpeg',
'/bin/ffmpeg'
];
let foundPath = false;
for (const path of priorityPaths) {
if (fs.existsSync(path)) {
systemFfmpegPath = path;
console.log(`[StreamRelay] Found FFmpeg at: ${path}`);
// If we found a path that is NOT the system default (which we know is broken), likely we are good.
// But even if it is system default, we use it if nothing else found.
if (path !== '/usr/bin/ffmpeg' && path !== '/bin/ffmpeg') {
console.log('[StreamRelay] Using generic/community FFmpeg (likely supports RTSP).');
} else {
console.warn('[StreamRelay] WARNING: Using system FFmpeg. RTSP might fail ("Protocol not found").');
}
foundPath = true;
break;
}
}
if (!foundPath) {
console.warn('[StreamRelay] No FFmpeg binary found in priority paths. Falling back to ffmpeg-static or default PATH.');
}
ffmpeg.setFfmpegPath(systemFfmpegPath);
// We don't set global ffmpeg path here immediately because we might want to switch per-stream or check system paths dynamically
// (though fluent-ffmpeg usage usually suggests setting it once if possible, but we check inside startFFmpeg for robustness)
class StreamRelay {
constructor(server) {
@ -74,8 +30,6 @@ class StreamRelay {
}
parseCameraId(url) {
// url format: /api/stream?cameraId=1 or /api/stream/1 (if path matching works that way, but ws path is fixed)
// Let's use query param: ws://host/api/stream?cameraId=1
try {
const params = new URLSearchParams(url.split('?')[1]);
return params.get('cameraId');
@ -104,19 +58,15 @@ class StreamRelay {
}
}
// Forcefully disconnect all clients to trigger reconnection
resetStream(cameraId) {
const stream = this.streams.get(cameraId);
if (stream) {
console.log(`Resetting stream for camera ${cameraId} (closing ${stream.clients.size} clients)...`);
// Close all clients. This will trigger 'close' event on ws,
// calling removeClient -> stopStream.
for (const client of stream.clients) {
if (client.readyState === WebSocket.OPEN) {
client.close(1000, "Stream Reset");
}
}
// Ensure stream is stopped even if no clients existed
this.stopStream(cameraId);
}
}
@ -133,7 +83,17 @@ class StreamRelay {
}
const camera = rows[0];
// Construct RTSP URL
// Check if streaming is enabled
// Ignore if IGNORE_CAMERA_STATUS is explicitly 'true' (for local dev)
if ((camera.is_active === 0 || camera.is_active === false)) {
if (process.env.IGNORE_CAMERA_STATUS === 'true') {
console.log(`[DEV OVERRIDE] Streaming disabled in DB for camera ${cameraId}, but forced ON by IGNORE_CAMERA_STATUS.`);
} else {
console.log(`Streaming is disabled for camera ${cameraId} (${camera.name}). skipping FFmpeg start.`);
return;
}
}
let rtspUrl = 'rtsp://';
if (camera.username && camera.password) {
const user = camera.rtsp_encoding ? encodeURIComponent(camera.username) : camera.username;
@ -144,71 +104,85 @@ class StreamRelay {
console.log(`Starting FFmpeg for camera ${cameraId} (${camera.name})...`);
const transportMode = camera.transport_mode === 'udp' ? 'udp' : 'tcp';
// 1. Determine FFmpeg Binary Path (v1.0.8 Fix)
let selectedFfmpegPath;
if (process.env.FORCE_STATIC_FFMPEG === 'true') {
selectedFfmpegPath = staticFfmpegBinary;
} else {
const systemFfmpegPath = '/var/packages/ffmpeg7/target/bin/ffmpeg';
// Check system paths, priority to Synology packages, fallback to static
if (fs.existsSync(systemFfmpegPath)) {
selectedFfmpegPath = systemFfmpegPath;
} else if (fs.existsSync('/var/packages/ffmpeg6/target/bin/ffmpeg')) {
selectedFfmpegPath = '/var/packages/ffmpeg6/target/bin/ffmpeg';
} else {
selectedFfmpegPath = staticFfmpegBinary;
}
}
// Determine Quality Scaling
// Set for this command (Note: fluent-ffmpeg setFfmpegPath is global, but usually fine if concurrent calls settle on same binary or if we just set it before command)
ffmpeg.setFfmpegPath(selectedFfmpegPath);
// 2. Transport Mode
const transportMode = process.env.CCTV_TRANSPORT_OVERRIDE || camera.transport_mode || 'tcp';
// 3. Unique User-Agent to prevent session conflicts
const userAgent = `SmartAsset-Relay-v1.0.8-${transportMode}`;
// Quality Scaling
let scaleFilter = [];
let videoBitrate = '1000k';
const qual = camera.quality || 'low';
if (qual === 'low') {
scaleFilter = ['-vf scale=640:-1'];
videoBitrate = '1000k';
scaleFilter = ['-vf scale=1280:-1']; // Using 720p even for low for better visibility, bitrate controls size
videoBitrate = '2000k';
} else if (qual === 'medium') {
scaleFilter = ['-vf scale=1280:-1'];
videoBitrate = '2500k';
videoBitrate = '4000k';
} else {
// Original - Restore high bitrate as requested
videoBitrate = '6000k';
}
// Compatibility: Synology FFmpeg might not support -rtsp_transport flag or strict ordering
// Try removing explicit transport mode if it fails, or rely on Auto.
const inputOptions = [];
const inputOptions = [
'-user_agent', userAgent
];
if (transportMode !== 'auto') {
// We restored this because we are now ensuring a proper FFmpeg version (v6/7 or static) is used.
// REVERT: User reported local Windows issues with this flag enforced.
// Commenting out to restore local functionality. Synology should still work via Auto or default.
// inputOptions.push(`-rtsp_transport ${transportMode}`);
inputOptions.push('-rtsp_transport', transportMode);
}
console.log(`[StreamRelay] Using FFmpeg binary: ${systemFfmpegPath}`);
console.log(`[StreamRelay v1.0.8] Using FFmpeg binary: ${selectedFfmpegPath}`);
console.log(`[StreamRelay v1.0.10] Transport Mode: ${transportMode} (Override: ${process.env.CCTV_TRANSPORT_OVERRIDE || 'none'})`);
console.log(`[StreamRelay v1.0.8] User-Agent: ${userAgent}`);
console.log(`[StreamRelay v1.0.8] Input Options:`, inputOptions);
const command = ffmpeg(rtspUrl)
.inputOptions(inputOptions)
// .inputOptions([
// `-rtsp_transport ${transportMode}`
// ])
.addOptions([
'-c:v mpeg1video', // Video codec for JSMpeg
'-f mpegts', // Output format
'-codec:a mp2', // Audio codec
`-b:v ${videoBitrate}`, // Dynamic Video bitrate
`-maxrate ${videoBitrate}`, // Cap max bitrate
`-bufsize ${videoBitrate}`,
'-r 30', // FPS 30 (Restored)
'-g 30', // GOP size 30 (1 keyframe per sec)
'-bf 0', // No B-frames
...scaleFilter
])
const command = ffmpeg(rtspUrl);
if (inputOptions.length > 0) {
command.inputOptions(inputOptions);
}
command.addOptions([
'-c:v mpeg1video',
'-f mpegts',
'-codec:a mp2',
`-b:v ${videoBitrate}`,
`-maxrate ${videoBitrate}`,
`-bufsize ${videoBitrate}`,
'-r 30',
'-g 30',
'-bf 0',
...scaleFilter
])
.on('start', (cmdLine) => {
console.log('FFmpeg started:', cmdLine);
})
.on('error', (err) => {
// Only log if not killed manually (checking active stream usually tricky, but error msg usually enough)
if (!err.message.includes('SIGKILL')) {
console.error('FFmpeg error:', err.message);
// If error occurs, maybe stop stream?
// For reload, we might retry? simple stop for now.
this.stopStream(cameraId);
}
})
.on('end', () => {
console.log('FFmpeg exited');
// We don't indiscriminately stopStream here because 'reload' causes an exit too.
// But 'stopStream' checks if process matches?
// Let's rely on the process being replaced or nullified.
});
stream.process = command;

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "1.0.0",
"version": "0.1.0",
"description": "",
"main": "index.js",
"scripts": {
@ -27,4 +27,4 @@
"devDependencies": {
"nodemon": "^3.1.11"
}
}
}

View File

@ -19,6 +19,7 @@ interface Camera {
rtsp_encoding?: boolean;
quality?: 'low' | 'medium' | 'original';
display_order?: number;
is_active?: boolean | number;
}
// Wrap Camera Card with Sortable
@ -138,6 +139,31 @@ export function MonitoringPage() {
}
};
const handleToggleStatus = async (camera: Camera) => {
// Optimistic UI Update: Calculate new status
const currentStatus = camera.is_active !== 0 && camera.is_active !== false;
const newStatus = !currentStatus;
// Immediately update UI state
setCameras(prev => prev.map(c =>
c.id === camera.id ? { ...c, is_active: newStatus ? 1 : 0 } : c
));
try {
// Send request to server
await apiClient.patch(`/cameras/${camera.id}/status`, { is_active: newStatus });
// Only silent fetch to sync eventually, no alert needed
fetchCameras();
} catch (err) {
console.error('Failed to toggle status', err);
// Revert UI on failure
setCameras(prev => prev.map(c =>
c.id === camera.id ? { ...c, is_active: currentStatus ? 1 : 0 } : c
));
alert('상태 변경 실패 (되돌리기)');
}
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
@ -254,6 +280,17 @@ export function MonitoringPage() {
{/* Overlay Controls */}
{user?.role === 'admin' && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2" onPointerDown={(e) => e.stopPropagation()}>
<button
onClick={() => handleToggleStatus(camera)}
className={`${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-orange-500/80 hover:bg-orange-600' : 'bg-green-600/80 hover:bg-green-700'} text-white p-2 rounded-full`}
title={(camera.is_active !== 0 && camera.is_active !== false) ? "스트리밍 중지 (Pause)" : "스트리밍 시작 (Play)"}
>
{(camera.is_active !== 0 && camera.is_active !== false) ? (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
)}
</button>
<button
onClick={() => handlePing(camera.id)}
className="bg-green-600/80 text-white p-2 rounded-full hover:bg-green-700"
@ -279,8 +316,8 @@ export function MonitoringPage() {
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
<h3 className="text-white font-medium flex items-center gap-2">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
{camera.name}
<span className={`w-2 h-2 rounded-full ${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></span>
{camera.name} {(camera.is_active === 0 || camera.is_active === false) && <span className="text-xs text-slate-400">()</span>}
</h3>
{/* IP/Port Hidden as requested */}
</div>
@ -314,77 +351,19 @@ export function MonitoringPage() {
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> </label>
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
required
className="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="예: 정문 CCTV"
/>
<input type="text" name="name" value={formData.name} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="예: 정문 CCTV" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">IP </label>
<input
type="text"
name="ip_address"
value={formData.ip_address}
onChange={handleInputChange}
required
className="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="192.168.1.100"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label>
<input
type="number"
name="port"
value={formData.port}
onChange={handleInputChange}
className="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="554"
/>
</div>
<div><label className="block text-sm font-medium text-slate-700 mb-1">IP </label><input type="text" name="ip_address" value={formData.ip_address} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="192.168.1.100" /></div>
<div><label className="block text-sm font-medium text-slate-700 mb-1"></label><input type="number" name="port" value={formData.port} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="554" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">RTSP </label>
<input
type="text"
name="username"
value={formData.username}
onChange={handleInputChange}
className="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="admin"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">RTSP </label>
<input
type="password"
name="password"
value={formData.password}
onChange={handleInputChange}
className="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
/>
</div>
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP </label><input type="text" name="username" value={formData.username} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="admin" /></div>
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP </label><input type="password" name="password" value={formData.password} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" /></div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> </label>
<input
type="text"
name="stream_path"
value={formData.stream_path}
onChange={handleInputChange}
className="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
placeholder="/stream1"
/>
<p className="text-xs text-slate-500 mt-1">
TAPO C200 : /stream1 () /stream2 ()
</p>
<input type="text" name="stream_path" value={formData.stream_path} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="/stream1" />
</div>
{/* Advanced Settings */}
@ -395,12 +374,7 @@ export function MonitoringPage() {
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1"> (Transport)</label>
<select
name="transport_mode"
value={formData.transport_mode || 'tcp'}
onChange={(e) => setFormData(prev => ({ ...prev, transport_mode: e.target.value as any }))}
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none"
>
<select name="transport_mode" value={formData.transport_mode || 'tcp'} onChange={(e) => setFormData(prev => ({ ...prev, transport_mode: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
<option value="tcp">TCP ( - )</option>
<option value="udp">UDP ( - )</option>
<option value="auto">Auto</option>
@ -408,50 +382,24 @@ export function MonitoringPage() {
</div>
<div className="flex items-center">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
name="rtsp_encoding"
checked={!!formData.rtsp_encoding}
onChange={(e) => setFormData(prev => ({ ...prev, rtsp_encoding: e.target.checked }))}
className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500"
/>
<input type="checkbox" name="rtsp_encoding" checked={!!formData.rtsp_encoding} onChange={(e) => setFormData(prev => ({ ...prev, rtsp_encoding: e.target.checked }))} className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500" />
<span className="text-sm text-slate-600"> </span>
</label>
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-slate-600 mb-1"> (Quality)</label>
<select
name="quality"
value={formData.quality || 'low'}
onChange={(e) => setFormData(prev => ({ ...prev, quality: e.target.value as any }))}
className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none"
>
<select name="quality" value={formData.quality || 'low'} onChange={(e) => setFormData(prev => ({ ...prev, quality: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
<option value="low">Low (640px) - ()</option>
<option value="medium">Medium (1280px) - HD </option>
<option value="original">Original - ( )</option>
</select>
</div>
</div>
<p className="text-xs text-slate-400 mt-2">
* TCP로 . .
</p>
</div>
<div className="flex justify-end gap-3 mt-6">
<button
type="button"
onClick={() => setShowForm(false)}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition"
>
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50"
>
{loading ? '저장 중...' : '저장하기'}
</button>
<button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition"></button>
<button type="submit" disabled={loading} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50">{loading ? '저장 중...' : '저장하기'}</button>
</div>
</form>
</div>