Initialize project
This commit is contained in:
11
.gemini_instructions.md
Normal file
11
.gemini_instructions.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# Gemini Agent Instructions
|
||||
|
||||
This project follows specific architectural and operational rules defined in `PROJECT_RULES.md`.
|
||||
|
||||
**CRITICAL INSTRUCTION:**
|
||||
Before implementing any new features or modifying existing code, you MUST read and adhere to the guidelines in **`PROJECT_RULES.md`** located in the project root.
|
||||
|
||||
Key highlights:
|
||||
1. **Client-Side First:** Logic should be implemented in the Frontend (Vue 3) whenever possible to ensure privacy and reduce server load.
|
||||
2. **Stateless Server:** The Backend (FastAPI) should remain stateless. Use `backend/temp_uploads` for temporary files and rely on the auto-cleanup scheduler.
|
||||
3. **UI/UX:** Use Global Navigation (GNB) instead of Sidebars (LNB).
|
||||
63
.gitignore
vendored
Normal file
63
.gitignore
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
# === General & OS ===
|
||||
.DS_Store
|
||||
.AppleDouble
|
||||
.LSOverride
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
desktop.ini
|
||||
*.log
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# === Frontend (Node/Vue/Vite) ===
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.env
|
||||
frontend/.env.local
|
||||
frontend/.env.*.local
|
||||
frontend/npm-debug.log*
|
||||
frontend/yarn-debug.log*
|
||||
frontend/yarn-error.log*
|
||||
frontend/pnpm-debug.log*
|
||||
frontend/coverage/
|
||||
frontend/.nyc_output/
|
||||
|
||||
# === Backend (Python/FastAPI) ===
|
||||
backend/__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# Virtual Environments
|
||||
backend/venv/
|
||||
backend/.venv/
|
||||
backend/env/
|
||||
backend/ENV/
|
||||
|
||||
# Distribution / Build
|
||||
backend/build/
|
||||
backend/dist/
|
||||
backend/*.egg-info/
|
||||
.eggs/
|
||||
backend/.eggs/
|
||||
|
||||
# Testing & Coverage
|
||||
backend/.coverage
|
||||
backend/htmlcov/
|
||||
backend/.pytest_cache/
|
||||
backend/.mypy_cache/
|
||||
backend/coverage.xml
|
||||
|
||||
# Local Settings & Secrets
|
||||
backend/.env
|
||||
backend/instance/
|
||||
|
||||
# Databases
|
||||
*.sqlite3
|
||||
*.db
|
||||
*.sqlite
|
||||
|
||||
# === Project Specific ===
|
||||
# Temporary file uploads (auto-cleaned)
|
||||
backend/temp_uploads/
|
||||
7
.sisyphus/ultrawork-state.json
Normal file
7
.sisyphus/ultrawork-state.json
Normal file
File diff suppressed because one or more lines are too long
48
AGENTS.md
Normal file
48
AGENTS.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# AGENTS.md - Context & Directives for AI Agents
|
||||
|
||||
This file defines the operating context for Sisyphus, OpenCode, and Gemini agents working on **Web Utils 2026**.
|
||||
|
||||
## 1. Core Philosophy: Client-Side First
|
||||
**CRITICAL RULE:** Do NOT implement server-side logic for tasks that can be done in the browser.
|
||||
- **Client-Side (Frontend):** Generators (UUID, Password), Formatters (JSON, XML), Encoders (Base64), Unit Converters.
|
||||
- **Server-Side (Backend):** Image processing, Video manipulation, Heavy file conversion, scraping (if CORS blocked).
|
||||
|
||||
## 2. Architecture Constraints
|
||||
|
||||
### Backend (FastAPI)
|
||||
- **Stateless:** NEVER store persistent user data.
|
||||
- **File Handling:** ALWAYS use `app.core.cleanup` utils. Files must go to `backend/temp_uploads` and be cleaned up.
|
||||
- **Registry:** New tools must be registered in `app.core.registry` to be visible to the frontend.
|
||||
|
||||
### Frontend (Vue 3)
|
||||
- **State:** Use Vue Composition API (`<script setup lang="ts">`).
|
||||
- **Styling:** Use Tailwind CSS utility classes. Avoid custom CSS files unless necessary.
|
||||
- **API:** Use `axios` for backend communication.
|
||||
|
||||
## 3. Project Structure Awareness
|
||||
- `backend/app/main.py`: Application entry point.
|
||||
- `backend/app/tools/`: Where server-side tool logic resides.
|
||||
- `frontend/src/views/`: Main pages.
|
||||
- `frontend/src/components/`: Shared components.
|
||||
|
||||
## 4. Coding Standards
|
||||
|
||||
### Python (Backend)
|
||||
- Use type hints for everything (Pydantic models).
|
||||
- Follow PEP 8.
|
||||
- Use `async def` for route handlers.
|
||||
|
||||
### TypeScript (Frontend)
|
||||
- Strict mode enabled.
|
||||
- No `any` types unless absolutely necessary (and commented).
|
||||
- Use `<script setup>` syntax.
|
||||
|
||||
## 5. Deployment Context
|
||||
- The project is designed to be containerizable.
|
||||
- Frontend is served statically in production, but runs on Vite dev server during development.
|
||||
|
||||
## 6. Common Tasks & patterns
|
||||
- **Adding a new Tool:**
|
||||
1. Determine if Frontend-only or Backend-required.
|
||||
2. If Backend: Create router in `backend/app/tools/`, register in `main.py` and `registry.py`.
|
||||
3. Frontend: Create view in `views/`, add route in `router/`.
|
||||
15
PROJECT_RULES.md
Normal file
15
PROJECT_RULES.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Project Rules & Guidelines
|
||||
|
||||
## 1. Client-Side First Policy (Privacy & Security)
|
||||
- **Generators & Simple Utilities:**
|
||||
- Logic for generating data (e.g., passwords, UUIDs, random strings) or processing simple text (e.g., JSON formatting, URL encoding) **MUST** be implemented on the **Client-side (Frontend)** whenever possible.
|
||||
- **Reason:** To protect user privacy, prevent sensitive data (like passwords) from ever touching the network/server, and improve responsiveness.
|
||||
- **Exception:** Operations requiring heavy computation (e.g., Image/Video processing) or specific server-side libraries (e.g., specific Python ecosystem tools not available in JS) may use the Backend.
|
||||
|
||||
## 2. Server-Side Data Handling
|
||||
- **Statelessness:** The server should remain stateless.
|
||||
- **Temporary Files:** If file upload is necessary for processing (e.g., Image Compression), files must be stored in a dedicated temporary directory (`backend/temp_uploads`) and **automatically cleaned up** after a short period (e.g., 10 minutes) or immediately after processing/download.
|
||||
|
||||
## 3. UI/UX
|
||||
- **Navigation:** Use the Global Navigation Bar (GNB) for main navigation. Avoid complex sidebars (LNB) for simple utility sites.
|
||||
- **Feedback:** Always provide visual feedback for actions (loading states, success/error messages).
|
||||
54
README.md
Normal file
54
README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Web Utils 2026
|
||||
|
||||
A privacy-focused, client-side first collection of web utilities.
|
||||
|
||||
## Overview
|
||||
**Web Utils 2026** is designed to handle most operations directly in the browser to ensure data privacy and speed. The server is used only for heavy computations or platform-specific tasks (like image processing).
|
||||
|
||||
## Features
|
||||
- **Client-Side First:** Logic for data generation and simple processing runs in the browser.
|
||||
- **Stateless Backend:** No user sessions or persistent data storage.
|
||||
- **Auto-Cleanup:** Temporary files for processing are automatically deleted.
|
||||
- **Dynamic Registry:** Tool discovery via backend API.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
### Frontend
|
||||
- **Framework:** Vue 3 (Composition API) + TypeScript
|
||||
- **Styling:** Tailwind CSS
|
||||
- **Build:** Vite
|
||||
|
||||
### Backend
|
||||
- **Framework:** FastAPI (Python)
|
||||
- **Scheduler:** APScheduler (for cleanup)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Node.js (v18+) or Bun
|
||||
- Python 3.10+
|
||||
|
||||
### Backend Setup
|
||||
```bash
|
||||
cd backend
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Run server
|
||||
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### Frontend Setup
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
|
||||
# Run development server
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
- `backend/`: FastAPI server and tool implementations.
|
||||
- `frontend/`: Vue 3 application source code.
|
||||
- `PROJECT_RULES.md`: Core development guidelines.
|
||||
52
backend/app/core/cleanup.py
Normal file
52
backend/app/core/cleanup.py
Normal file
@@ -0,0 +1,52 @@
|
||||
import shutil
|
||||
import time
|
||||
import os
|
||||
from pathlib import Path
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
|
||||
TEMP_DIR = Path("backend/temp_uploads")
|
||||
|
||||
def init_temp_dir():
|
||||
"""임시 디렉토리 생성 (없으면 생성)"""
|
||||
if not TEMP_DIR.exists():
|
||||
TEMP_DIR.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Created temp directory at: {TEMP_DIR.absolute()}")
|
||||
|
||||
def cleanup_old_files(max_age_seconds: int = 600):
|
||||
"""지정된 시간(기본 10분)보다 오래된 파일/폴더 삭제"""
|
||||
if not TEMP_DIR.exists():
|
||||
return
|
||||
|
||||
now = time.time()
|
||||
deleted_count = 0
|
||||
|
||||
for item in TEMP_DIR.iterdir():
|
||||
try:
|
||||
# 최종 수정 시간 확인
|
||||
item_mtime = item.stat().st_mtime
|
||||
if now - item_mtime > max_age_seconds:
|
||||
if item.is_dir():
|
||||
shutil.rmtree(item)
|
||||
else:
|
||||
item.unlink()
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print(f"Error deleting {item}: {e}")
|
||||
|
||||
if deleted_count > 0:
|
||||
print(f"Cleaned up {deleted_count} old items from temp directory.")
|
||||
|
||||
def cleanup_all():
|
||||
"""서버 시작 시 모든 임시 파일 삭제"""
|
||||
if TEMP_DIR.exists():
|
||||
shutil.rmtree(TEMP_DIR)
|
||||
init_temp_dir()
|
||||
print("Cleaned up all temporary files on startup.")
|
||||
|
||||
def start_scheduler():
|
||||
"""백그라운드 스케줄러 시작 (1분마다 정리 작업 실행)"""
|
||||
scheduler = BackgroundScheduler()
|
||||
# 10분(600초) 지난 파일 삭제 작업을 1분마다 수행
|
||||
scheduler.add_job(cleanup_old_files, 'interval', minutes=1, args=[600])
|
||||
scheduler.start()
|
||||
return scheduler
|
||||
164
backend/app/core/registry.py
Normal file
164
backend/app/core/registry.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ToolMetadata(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
description: str
|
||||
category: str
|
||||
tags: List[str]
|
||||
type: str # 'client' or 'server'
|
||||
endpoint: Optional[str] = None # Only for server type
|
||||
|
||||
# === 도구 등록소 ===
|
||||
# 새로운 기능을 추가할 때 여기만 수정하면 검색/메뉴에 자동 반영되도록 구성합니다.
|
||||
TOOLS_REGISTRY = [
|
||||
ToolMetadata(
|
||||
id="url-parser",
|
||||
name="URL Encoder/Decoder",
|
||||
description="Encodes or decodes a URL string.",
|
||||
category="Web",
|
||||
tags=["url", "encode", "decode", "percent-encoding"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="json-formatter",
|
||||
name="JSON Formatter",
|
||||
description="Prettify or minify JSON data.",
|
||||
category="Data",
|
||||
tags=["json", "pretty", "minify", "format"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="py-uuid",
|
||||
name="UUID Generator",
|
||||
description="Generates UUIDs locally (v1/v4).",
|
||||
category="Development",
|
||||
tags=["uuid", "guid", "random", "client-side"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="png-compressor",
|
||||
name="PNG Compressor",
|
||||
description="Compress PNG images using pngquant (Lossy, 60-80% reduction).",
|
||||
category="Image",
|
||||
tags=["image", "png", "compress", "pngquant", "optimize"],
|
||||
type="server",
|
||||
endpoint="/api/tools/png-compress"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="password-generator",
|
||||
name="Password Generator",
|
||||
description="Generate secure random passwords locally (Client-side).",
|
||||
category="Security",
|
||||
tags=["password", "random", "secure", "generator", "client-side"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="jwt-debugger",
|
||||
name="JWT Debugger",
|
||||
description="Decode and inspect JSON Web Tokens.",
|
||||
category="Security",
|
||||
tags=["jwt", "token", "decode", "security"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="cron-generator",
|
||||
name="Cron Schedule Generator",
|
||||
description="Generate and explain cron expressions.",
|
||||
category="Utility",
|
||||
tags=["cron", "schedule", "time", "generator"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="sql-formatter",
|
||||
name="SQL Formatter",
|
||||
description="Beautify and format SQL queries.",
|
||||
category="Formatters",
|
||||
tags=["sql", "format", "database", "query"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="diff-viewer",
|
||||
name="Diff Viewer",
|
||||
description="Compare text and see differences side-by-side.",
|
||||
category="Text",
|
||||
tags=["diff", "compare", "text", "code"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="qr-generator",
|
||||
name="QR Code Generator",
|
||||
description="Generate QR codes from text or URLs.",
|
||||
category="Generators",
|
||||
tags=["qr", "code", "generator", "image"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="base64-encoder",
|
||||
name="Base64 File Encoder",
|
||||
description="Convert files to Base64 strings.",
|
||||
category="Encoders",
|
||||
tags=["base64", "encode", "file", "image"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="json-converter",
|
||||
name="JSON Converter",
|
||||
description="Convert between JSON, CSV, and YAML.",
|
||||
category="Converters",
|
||||
tags=["json", "csv", "yaml", "convert"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="regex-tester",
|
||||
name="Regex Tester",
|
||||
description="Test regular expressions in real-time.",
|
||||
category="Text",
|
||||
tags=["regex", "test", "pattern", "match"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="docker-converter",
|
||||
name="Docker Converter",
|
||||
description="Convert between Docker Run and Docker Compose.",
|
||||
category="DevOps",
|
||||
tags=["docker", "compose", "convert", "devops"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="mock-data-generator",
|
||||
name="Mock Data Generator",
|
||||
description="Generate random user data for testing.",
|
||||
category="Generators",
|
||||
tags=["mock", "data", "fake", "generator"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="svg-optimizer",
|
||||
name="SVG Optimizer",
|
||||
description="Optimize and minify SVG code.",
|
||||
category="Image",
|
||||
tags=["svg", "optimize", "minify", "image"],
|
||||
type="client"
|
||||
),
|
||||
ToolMetadata(
|
||||
id="video-to-gif",
|
||||
name="Video to GIF",
|
||||
description="Convert video clips to GIF format.",
|
||||
category="Video",
|
||||
tags=["video", "gif", "convert", "ffmpeg"],
|
||||
type="server",
|
||||
endpoint="/api/tools/video/gif"
|
||||
)
|
||||
]
|
||||
|
||||
def search_tools(query: str = "") -> List[ToolMetadata]:
|
||||
if not query:
|
||||
return TOOLS_REGISTRY
|
||||
|
||||
q = query.lower()
|
||||
return [
|
||||
t for t in TOOLS_REGISTRY
|
||||
if q in t.name.lower() or q in t.description.lower() or any(q in tag for tag in t.tags)
|
||||
]
|
||||
51
backend/app/main.py
Normal file
51
backend/app/main.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add backend directory to sys.path to resolve 'app' module
|
||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app.core.registry import TOOLS_REGISTRY, search_tools
|
||||
from app.core.cleanup import start_scheduler, cleanup_all, init_temp_dir
|
||||
from app.tools import server_ops, image_ops, video_ops
|
||||
import contextlib
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
cleanup_all()
|
||||
scheduler = start_scheduler()
|
||||
yield
|
||||
scheduler.shutdown()
|
||||
|
||||
app = FastAPI(title="Web Utils 2026", description="Personal Web Utilities Server", lifespan=lifespan)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(server_ops.router, prefix="/api/tools", tags=["tools"])
|
||||
app.include_router(image_ops.router, prefix="/api/tools", tags=["images"])
|
||||
app.include_router(video_ops.router, prefix="/api/tools/video", tags=["video"])
|
||||
|
||||
@app.get("/api/registry")
|
||||
def get_registry(q: str | None = None):
|
||||
"""프론트엔드 메뉴 구성을 위한 도구 목록 반환"""
|
||||
return search_tools(q or "")
|
||||
|
||||
# Static Files Serving (Frontend 빌드 결과물 서빙용 - 배포 시 활성화)
|
||||
# 개발 중에는 Frontend Dev Server(Vite)를 별도로 띄우는 것이 일반적이지만,
|
||||
# 최종 배포 형태를 고려해 코드를 남겨둡니다.
|
||||
# frontend_dist = "../frontend/dist"
|
||||
# if os.path.exists(frontend_dist):
|
||||
# app.mount("/", StaticFiles(directory=frontend_dist, html=True), name="static")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
# Use 'app.main:app' to ensure reliable reloading from backend root
|
||||
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=True)
|
||||
144
backend/app/tools/image_ops.py
Normal file
144
backend/app/tools/image_ops.py
Normal file
@@ -0,0 +1,144 @@
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from fastapi import APIRouter, UploadFile, File, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from app.core.cleanup import TEMP_DIR
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
def get_png_color_type(file_path: Path) -> int | None:
|
||||
"""PNG 파일의 color type 확인 (참조 문서 로직)"""
|
||||
try:
|
||||
with open(file_path, "rb") as f:
|
||||
header = f.read(26)
|
||||
if header[:8] != b'\x89PNG\r\n\x1a\n':
|
||||
return None
|
||||
return header[25]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@router.post("/png-compress")
|
||||
async def compress_png_files(files: List[UploadFile] = File(...)):
|
||||
"""
|
||||
업로드된 PNG 파일들을 pngquant로 압축하고 통계를 반환합니다.
|
||||
"""
|
||||
# 작업 ID 생성 (폴더 분리용)
|
||||
job_id = str(uuid.uuid4())
|
||||
job_dir = TEMP_DIR / job_id
|
||||
job_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
results = []
|
||||
compressed_files = []
|
||||
|
||||
try:
|
||||
for file in files:
|
||||
if not file.filename.lower().endswith('.png'):
|
||||
continue
|
||||
|
||||
# 1. 파일 저장
|
||||
file_path = job_dir / file.filename
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
orig_size = file_path.stat().st_size
|
||||
|
||||
# 2. 이미 압축된 파일인지 확인 (Color Type 3 = Indexed)
|
||||
color_type = get_png_color_type(file_path)
|
||||
if color_type == 3:
|
||||
# 이미 압축됨 -> 그냥 결과에 추가 (압축 안 함)
|
||||
results.append({
|
||||
"filename": file.filename,
|
||||
"original_size": orig_size,
|
||||
"compressed_size": orig_size,
|
||||
"ratio": 0.0,
|
||||
"status": "Skipped (Already Indexed)"
|
||||
})
|
||||
compressed_files.append(file_path)
|
||||
continue
|
||||
|
||||
# 3. pngquant 실행
|
||||
output_path = job_dir / f"compressed_{file.filename}"
|
||||
try:
|
||||
# --force: 덮어쓰기 허용, --quality: 품질 설정
|
||||
cmd = [
|
||||
"pngquant",
|
||||
"--quality", "65-80",
|
||||
"--force",
|
||||
"--output", str(output_path),
|
||||
str(file_path)
|
||||
]
|
||||
subprocess.run(cmd, check=True, capture_output=True)
|
||||
|
||||
if output_path.exists():
|
||||
comp_size = output_path.stat().st_size
|
||||
ratio = (1 - comp_size / orig_size) * 100
|
||||
|
||||
results.append({
|
||||
"filename": file.filename,
|
||||
"original_size": orig_size,
|
||||
"compressed_size": comp_size,
|
||||
"ratio": round(ratio, 1),
|
||||
"status": "Success"
|
||||
})
|
||||
compressed_files.append(output_path)
|
||||
else:
|
||||
raise Exception("Output file not created")
|
||||
|
||||
except subprocess.CalledProcessError:
|
||||
# 압축 실패 (품질 기준 미달 등) -> 원본 유지
|
||||
results.append({
|
||||
"filename": file.filename,
|
||||
"original_size": orig_size,
|
||||
"compressed_size": orig_size,
|
||||
"ratio": 0.0,
|
||||
"status": "Failed (Quality check)"
|
||||
})
|
||||
compressed_files.append(file_path)
|
||||
|
||||
# 4. ZIP 생성
|
||||
zip_filename = f"compressed_images_{job_id}.zip"
|
||||
zip_path = TEMP_DIR / zip_filename
|
||||
|
||||
with zipfile.ZipFile(zip_path, 'w') as zipf:
|
||||
for f in compressed_files:
|
||||
# ZIP 내부에는 원래 파일명으로 저장
|
||||
arcname = f.name.replace("compressed_", "")
|
||||
zipf.write(f, arcname)
|
||||
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"zip_filename": zip_filename,
|
||||
"stats": results
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
cleanup_files([job_dir])
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@router.get("/download/{filename}")
|
||||
async def download_file(filename: str):
|
||||
file_path = TEMP_DIR / filename
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
# 즉시 삭제하지 않고 스케줄러(10분 후)에 맡김
|
||||
return FileResponse(file_path, filename=filename)
|
||||
|
||||
@router.get("/download-single/{job_id}/{filename}")
|
||||
async def download_single_file(job_id: str, filename: str):
|
||||
"""개별 파일 다운로드 (삭제 예약 없음 - 전체 ZIP 다운로드 등을 위해 유지)"""
|
||||
file_path = TEMP_DIR / job_id / f"compressed_{filename}"
|
||||
|
||||
# 만약 compressed_ 접두사가 없다면 원본 파일일 수도 있음 (압축 실패 시)
|
||||
if not file_path.exists():
|
||||
file_path = TEMP_DIR / job_id / filename
|
||||
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=404, detail="File not found")
|
||||
|
||||
return FileResponse(file_path, filename=filename)
|
||||
12
backend/app/tools/server_ops.py
Normal file
12
backend/app/tools/server_ops.py
Normal file
@@ -0,0 +1,12 @@
|
||||
import uuid
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# --- Request/Response Models ---
|
||||
|
||||
# --- Server-side Logic Implementations ---
|
||||
# Currently empty as UUID and Password generation moved to client-side.
|
||||
# Future server-side logic can be added here.
|
||||
|
||||
60
backend/app/tools/video_ops.py
Normal file
60
backend/app/tools/video_ops.py
Normal file
@@ -0,0 +1,60 @@
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, UploadFile, HTTPException, BackgroundTasks
|
||||
from fastapi.responses import FileResponse
|
||||
import ffmpeg
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
TEMP_DIR = Path("temp_uploads")
|
||||
TEMP_DIR.mkdir(exist_ok=True)
|
||||
|
||||
def cleanup_file(path: Path):
|
||||
try:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
except Exception as e:
|
||||
print(f"Error cleaning up file {path}: {e}")
|
||||
|
||||
@router.post("/gif")
|
||||
async def convert_video_to_gif(file: UploadFile, background_tasks: BackgroundTasks):
|
||||
if not (file.content_type or "").startswith("video/"):
|
||||
raise HTTPException(status_code=400, detail="File must be a video")
|
||||
|
||||
file_id = str(uuid.uuid4())
|
||||
input_path = TEMP_DIR / f"{file_id}_{file.filename}"
|
||||
output_filename = f"{file_id}.gif"
|
||||
output_path = TEMP_DIR / output_filename
|
||||
|
||||
try:
|
||||
with input_path.open("wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
stream = ffmpeg.input(str(input_path))
|
||||
stream = ffmpeg.filter(stream, 'fps', fps=10)
|
||||
stream = ffmpeg.filter(stream, 'scale', 320, -1)
|
||||
stream = ffmpeg.output(stream, str(output_path))
|
||||
ffmpeg.run(stream, capture_stdout=True, capture_stderr=True)
|
||||
|
||||
background_tasks.add_task(cleanup_file, input_path)
|
||||
background_tasks.add_task(cleanup_file, output_path)
|
||||
|
||||
return FileResponse(
|
||||
output_path,
|
||||
media_type="image/gif",
|
||||
filename="converted.gif"
|
||||
)
|
||||
|
||||
except ffmpeg.Error as e:
|
||||
cleanup_file(input_path)
|
||||
if output_path.exists():
|
||||
cleanup_file(output_path)
|
||||
print(e.stderr.decode('utf8'))
|
||||
raise HTTPException(status_code=500, detail="FFmpeg conversion failed")
|
||||
except Exception as e:
|
||||
cleanup_file(input_path)
|
||||
if output_path.exists():
|
||||
cleanup_file(output_path)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
5
backend/requirements.txt
Normal file
5
backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi==0.109.0
|
||||
uvicorn==0.27.0
|
||||
pydantic==2.6.0
|
||||
python-multipart
|
||||
apscheduler==3.10.4
|
||||
417
frontend/bun.lock
Normal file
417
frontend/bun.lock
Normal file
@@ -0,0 +1,417 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "web-utils-frontend",
|
||||
"dependencies": {
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.5",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^1.8.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
|
||||
|
||||
"@heroicons/vue": ["@heroicons/vue@2.2.0", "", { "peerDependencies": { "vue": ">= 3" } }, "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
|
||||
|
||||
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
|
||||
|
||||
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
|
||||
|
||||
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA=="],
|
||||
|
||||
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.0", "", { "os": "none", "cpu": "arm64" }, "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
|
||||
|
||||
"@volar/language-core": ["@volar/language-core@1.11.1", "", { "dependencies": { "@volar/source-map": "1.11.1" } }, "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw=="],
|
||||
|
||||
"@volar/source-map": ["@volar/source-map@1.11.1", "", { "dependencies": { "muggle-string": "^0.3.1" } }, "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg=="],
|
||||
|
||||
"@volar/typescript": ["@volar/typescript@1.11.1", "", { "dependencies": { "@volar/language-core": "1.11.1", "path-browserify": "^1.0.1" } }, "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.27", "", { "dependencies": { "@babel/parser": "^7.28.5", "@vue/shared": "3.5.27", "entities": "^7.0.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.27", "", { "dependencies": { "@vue/compiler-core": "3.5.27", "@vue/shared": "3.5.27" } }, "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.27", "", { "dependencies": { "@babel/parser": "^7.28.5", "@vue/compiler-core": "3.5.27", "@vue/compiler-dom": "3.5.27", "@vue/compiler-ssr": "3.5.27", "@vue/shared": "3.5.27", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.27", "", { "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/shared": "3.5.27" } }, "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
|
||||
|
||||
"@vue/language-core": ["@vue/language-core@1.8.27", "", { "dependencies": { "@volar/language-core": "~1.11.1", "@volar/source-map": "~1.11.1", "@vue/compiler-dom": "^3.3.0", "@vue/shared": "^3.3.0", "computeds": "^0.0.1", "minimatch": "^9.0.3", "muggle-string": "^0.3.1", "path-browserify": "^1.0.1", "vue-template-compiler": "^2.7.14" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.27", "", { "dependencies": { "@vue/shared": "3.5.27" } }, "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.27", "", { "dependencies": { "@vue/reactivity": "3.5.27", "@vue/shared": "3.5.27" } }, "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.27", "", { "dependencies": { "@vue/reactivity": "3.5.27", "@vue/runtime-core": "3.5.27", "@vue/shared": "3.5.27", "csstype": "^3.2.3" } }, "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.27", "", { "dependencies": { "@vue/compiler-ssr": "3.5.27", "@vue/shared": "3.5.27" }, "peerDependencies": { "vue": "3.5.27" } }, "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.27", "", {}, "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ=="],
|
||||
|
||||
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
|
||||
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
|
||||
|
||||
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
|
||||
|
||||
"axios": ["axios@1.13.4", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="],
|
||||
|
||||
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
|
||||
|
||||
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
|
||||
|
||||
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"computeds": ["computeds@0.0.1", "", {}, "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q=="],
|
||||
|
||||
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
|
||||
|
||||
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
|
||||
|
||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.279", "", {}, "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
|
||||
|
||||
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
|
||||
|
||||
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
|
||||
|
||||
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
|
||||
|
||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
|
||||
|
||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||
|
||||
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
|
||||
|
||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
|
||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||
|
||||
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
|
||||
|
||||
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
|
||||
|
||||
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
|
||||
|
||||
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
|
||||
|
||||
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
|
||||
|
||||
"muggle-string": ["muggle-string@0.3.1", "", {}, "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg=="],
|
||||
|
||||
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
|
||||
|
||||
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
|
||||
|
||||
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
|
||||
|
||||
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
|
||||
|
||||
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
|
||||
|
||||
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
|
||||
|
||||
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
|
||||
|
||||
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
|
||||
|
||||
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
|
||||
|
||||
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
|
||||
|
||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
|
||||
|
||||
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
|
||||
|
||||
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
|
||||
|
||||
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
|
||||
|
||||
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
|
||||
|
||||
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
|
||||
|
||||
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
|
||||
|
||||
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
|
||||
"uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
|
||||
|
||||
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
|
||||
|
||||
"vue": ["vue@3.5.27", "", { "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", "@vue/runtime-dom": "3.5.27", "@vue/server-renderer": "3.5.27", "@vue/shared": "3.5.27" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw=="],
|
||||
|
||||
"vue-router": ["vue-router@4.6.4", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
|
||||
|
||||
"vue-template-compiler": ["vue-template-compiler@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ=="],
|
||||
|
||||
"vue-tsc": ["vue-tsc@1.8.27", "", { "dependencies": { "@volar/typescript": "~1.11.1", "@vue/language-core": "1.8.27", "semver": "^7.5.4" }, "peerDependencies": { "typescript": "*" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg=="],
|
||||
|
||||
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
}
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Web Utils 2026</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
</head>
|
||||
<body class="bg-gray-50 text-gray-900">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2780
frontend/package-lock.json
generated
Normal file
2780
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
40
frontend/package.json
Normal file
40
frontend/package.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"name": "web-utils-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@faker-js/faker": "^10.2.0",
|
||||
"@heroicons/vue": "^2.1.1",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"axios": "^1.6.0",
|
||||
"composerize": "^1.7.5",
|
||||
"cronstrue": "^3.9.0",
|
||||
"decomposerize": "^1.4.4",
|
||||
"diff": "^8.0.3",
|
||||
"js-yaml": "^4.1.1",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"papaparse": "^5.5.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"sql-formatter": "^15.7.0",
|
||||
"svgo": "^4.0.0",
|
||||
"uuid": "^13.0.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-diff": "^1.2.4",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.5",
|
||||
"typescript": "^5.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^1.8.0"
|
||||
}
|
||||
}
|
||||
19
frontend/src/App.vue
Normal file
19
frontend/src/App.vue
Normal file
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import GlobalNavBar from './components/GlobalNavBar.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-100 flex flex-col">
|
||||
<GlobalNavBar />
|
||||
|
||||
<main class="flex-1 max-w-7xl w-full mx-auto p-6">
|
||||
<router-view></router-view>
|
||||
</main>
|
||||
|
||||
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||
<div class="max-w-7xl mx-auto py-6 px-4 text-center text-sm text-gray-500">
|
||||
© 2026 Web Utils. Powered by Vue 3 & FastAPI.
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
32
frontend/src/components/GlobalNavBar.vue
Normal file
32
frontend/src/components/GlobalNavBar.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="bg-gray-800 text-white shadow-md">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<!-- Logo / Home Link -->
|
||||
<div class="flex items-center cursor-pointer" @click="router.push('/')">
|
||||
<span class="text-xl font-bold tracking-tight">Web Utils 2026</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation Links -->
|
||||
<div class="hidden md:block">
|
||||
<div class="ml-10 flex items-baseline space-x-4">
|
||||
<router-link
|
||||
to="/"
|
||||
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 transition"
|
||||
active-class="bg-gray-900 text-white"
|
||||
>
|
||||
Dashboard
|
||||
</router-link>
|
||||
<!-- Add more global links here if needed -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
37
frontend/src/components/ToolLayout.vue
Normal file
37
frontend/src/components/ToolLayout.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<div class="h-full flex flex-col bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Header -->
|
||||
<header class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700 px-6 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<slot name="header">
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Tool Name</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Tool description goes here.</p>
|
||||
</slot>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<slot name="actions"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="flex-1 flex overflow-hidden">
|
||||
<!-- Options Sidebar (Optional) -->
|
||||
<aside v-if="$slots.options" class="w-80 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 overflow-y-auto p-6 flex-shrink-0 z-10">
|
||||
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-4">Configuration</h2>
|
||||
<slot name="options"></slot>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="flex-1 overflow-auto p-6 relative">
|
||||
<div class="max-w-[90%] mx-auto h-full">
|
||||
<slot name="content"></slot>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// ToolLayout.vue - Shared wrapper for all Web Utils tools
|
||||
</script>
|
||||
11
frontend/src/main.ts
Normal file
11
frontend/src/main.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createApp } from 'vue'
|
||||
import VueDiff from 'vue-diff'
|
||||
import 'vue-diff/dist/index.css'
|
||||
import './style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(VueDiff)
|
||||
app.mount('#app')
|
||||
121
frontend/src/router/index.ts
Normal file
121
frontend/src/router/index.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Dashboard from '../views/Dashboard.vue'
|
||||
|
||||
// Tools
|
||||
import UrlParser from '../views/tools/UrlParser.vue'
|
||||
import JsonFormatter from '../views/tools/JsonFormatter.vue'
|
||||
import UuidGen from '../views/tools/UuidGen.vue'
|
||||
import PngCompressor from '../views/tools/PngCompressor.vue'
|
||||
import PasswordGen from '../views/tools/PasswordGen.vue'
|
||||
import JwtDebugger from '../views/tools/JwtDebugger.vue'
|
||||
import CronGen from '../views/tools/CronGen.vue'
|
||||
import SqlFormatter from '../views/tools/SqlFormatter.vue'
|
||||
import DiffViewer from '../views/tools/DiffViewer.vue'
|
||||
import QrGenerator from '../views/tools/QrGenerator.vue'
|
||||
import Base64Encoder from '../views/tools/Base64Encoder.vue'
|
||||
import JsonConverter from '../views/tools/JsonConverter.vue'
|
||||
import RegexTester from '../views/tools/RegexTester.vue'
|
||||
import DockerConverter from '../views/tools/DockerConverter.vue'
|
||||
import MockDataGen from '../views/tools/MockDataGen.vue'
|
||||
import SvgOptimizer from '../views/tools/SvgOptimizer.vue'
|
||||
import VideoToGif from '../views/tools/VideoToGif.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Dashboard',
|
||||
component: Dashboard
|
||||
},
|
||||
{
|
||||
path: '/tools/url-parser',
|
||||
name: 'URL Encoder/Decoder',
|
||||
component: UrlParser
|
||||
},
|
||||
{
|
||||
path: '/tools/json-formatter',
|
||||
name: 'JSON Formatter',
|
||||
component: JsonFormatter
|
||||
},
|
||||
{
|
||||
path: '/tools/py-uuid',
|
||||
name: 'UUID Generator',
|
||||
component: UuidGen
|
||||
},
|
||||
{
|
||||
path: '/tools/png-compressor',
|
||||
name: 'PNG Compressor',
|
||||
component: PngCompressor
|
||||
},
|
||||
{
|
||||
path: '/tools/password-generator',
|
||||
name: 'Password Generator',
|
||||
component: PasswordGen
|
||||
},
|
||||
{
|
||||
path: '/tools/jwt-debugger',
|
||||
name: 'JWT Debugger',
|
||||
component: JwtDebugger
|
||||
},
|
||||
{
|
||||
path: '/tools/cron-generator',
|
||||
name: 'Cron Schedule Generator',
|
||||
component: CronGen
|
||||
},
|
||||
{
|
||||
path: '/tools/sql-formatter',
|
||||
name: 'SQL Formatter',
|
||||
component: SqlFormatter
|
||||
},
|
||||
{
|
||||
path: '/tools/diff-viewer',
|
||||
name: 'Diff Viewer',
|
||||
component: DiffViewer
|
||||
},
|
||||
{
|
||||
path: '/tools/qr-generator',
|
||||
name: 'QR Code Generator',
|
||||
component: QrGenerator
|
||||
},
|
||||
{
|
||||
path: '/tools/base64-encoder',
|
||||
name: 'Base64 File Encoder',
|
||||
component: Base64Encoder
|
||||
},
|
||||
{
|
||||
path: '/tools/json-converter',
|
||||
name: 'JSON Converter',
|
||||
component: JsonConverter
|
||||
},
|
||||
{
|
||||
path: '/tools/regex-tester',
|
||||
name: 'Regex Tester',
|
||||
component: RegexTester
|
||||
},
|
||||
{
|
||||
path: '/tools/docker-converter',
|
||||
name: 'Docker Converter',
|
||||
component: DockerConverter
|
||||
},
|
||||
{
|
||||
path: '/tools/mock-data-generator',
|
||||
name: 'Mock Data Generator',
|
||||
component: MockDataGen
|
||||
},
|
||||
{
|
||||
path: '/tools/svg-optimizer',
|
||||
name: 'SVG Optimizer',
|
||||
component: SvgOptimizer
|
||||
},
|
||||
{
|
||||
path: '/tools/video-to-gif',
|
||||
name: 'Video to GIF',
|
||||
component: VideoToGif
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes
|
||||
})
|
||||
|
||||
export default router
|
||||
14
frontend/src/style.css
Normal file
14
frontend/src/style.css
Normal file
@@ -0,0 +1,14 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
input[type="text"],
|
||||
input[type="number"],
|
||||
input[type="password"],
|
||||
input[type="email"],
|
||||
textarea,
|
||||
select {
|
||||
@apply border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
}
|
||||
68
frontend/src/utils/csvDownloader.ts
Normal file
68
frontend/src/utils/csvDownloader.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Converts an array of strings or objects to CSV and triggers a download.
|
||||
* @param data Array of data (strings or objects)
|
||||
* @param filename Name of the file to download (without extension)
|
||||
* @param headers Optional headers for object arrays. If not provided, keys of the first object are used.
|
||||
*/
|
||||
export const downloadCsv = (data: any[], filename: string, headers?: string[]) => {
|
||||
if (!data || data.length === 0) {
|
||||
alert("No data to download.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Add BOM for Excel compatibility with UTF-8
|
||||
let csvContent = "\uFEFF";
|
||||
|
||||
// Helper to safely escape CSV fields
|
||||
const escapeCsvField = (field: any): string => {
|
||||
if (field === null || field === undefined) {
|
||||
return '""';
|
||||
}
|
||||
const stringField = String(field);
|
||||
// Escape double quotes by doubling them (" -> "")
|
||||
return `"${stringField.replace(/"/g, '""')}"`;
|
||||
};
|
||||
|
||||
// 1. Determine Headers
|
||||
let csvHeaders: string[] = [];
|
||||
const isSimpleArray = typeof data[0] !== 'object' || data[0] === null;
|
||||
|
||||
if (headers) {
|
||||
csvHeaders = headers;
|
||||
} else if (!isSimpleArray) {
|
||||
csvHeaders = Object.keys(data[0]);
|
||||
} else {
|
||||
csvHeaders = ["Value"];
|
||||
}
|
||||
|
||||
// Write Headers
|
||||
csvContent += csvHeaders.map(escapeCsvField).join(",") + "\n";
|
||||
|
||||
// 2. Build Rows
|
||||
data.forEach((row) => {
|
||||
if (isSimpleArray) {
|
||||
csvContent += escapeCsvField(row) + "\n";
|
||||
} else {
|
||||
const rowString = csvHeaders.map(header => {
|
||||
return escapeCsvField(row[header]);
|
||||
}).join(",");
|
||||
csvContent += rowString + "\n";
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Trigger Download
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement("a");
|
||||
|
||||
if (navigator.msSaveBlob) { // IE 10+
|
||||
navigator.msSaveBlob(blob, `${filename}.csv`);
|
||||
} else {
|
||||
const url = URL.createObjectURL(blob);
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("download", `${filename}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url); // Clean up memory
|
||||
}
|
||||
};
|
||||
121
frontend/src/views/Dashboard.vue
Normal file
121
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
interface Tool {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tags: string[];
|
||||
type: 'client' | 'server';
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const tools = ref<Tool[]>([])
|
||||
const searchQuery = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
// Fetch tools from Backend Registry
|
||||
const fetchTools = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/registry')
|
||||
if (res.ok) {
|
||||
tools.value = await res.json()
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch registry:", e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchTools()
|
||||
})
|
||||
|
||||
const filteredTools = computed(() => {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return tools.value.filter(t =>
|
||||
t.name.toLowerCase().includes(q) ||
|
||||
t.description.toLowerCase().includes(q) ||
|
||||
t.tags.some(tag => tag.toLowerCase().includes(q))
|
||||
)
|
||||
})
|
||||
|
||||
const navigateToTool = (toolId: string) => {
|
||||
router.push(`/tools/${toolId}`)
|
||||
}
|
||||
|
||||
const getTagColor = (type: string) => {
|
||||
return type === 'server'
|
||||
? 'bg-orange-50 text-orange-700 border-orange-200'
|
||||
: 'bg-blue-50 text-blue-700 border-blue-200'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="mb-12 text-center">
|
||||
<h1 class="text-4xl font-extrabold text-gray-800 mb-4">Web Utils 2026</h1>
|
||||
<p class="text-lg text-gray-600 max-w-2xl mx-auto">
|
||||
A powerful collection of developer utilities, combining the speed of Vue 3 with the capabilities of Python.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div class="mb-10 max-w-2xl mx-auto">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search for tools..."
|
||||
class="w-full pl-12 pr-4 py-4 rounded-xl border border-gray-200 focus:ring-4 focus:ring-blue-100 focus:border-blue-500 shadow-sm text-lg transition"
|
||||
>
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grid -->
|
||||
<div v-if="loading" class="text-center py-12">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
|
||||
<p class="mt-4 text-gray-500">Loading tools...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div
|
||||
v-for="tool in filteredTools"
|
||||
:key="tool.id"
|
||||
@click="navigateToTool(tool.id)"
|
||||
class="bg-white rounded-xl shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-200 cursor-pointer border border-gray-100 overflow-hidden flex flex-col h-full"
|
||||
>
|
||||
<div class="p-6 flex-1">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<h3 class="text-xl font-bold text-gray-800">{{ tool.name }}</h3>
|
||||
<span class="px-2 py-1 rounded-md text-xs font-bold uppercase tracking-wide border" :class="getTagColor(tool.type)">
|
||||
{{ tool.type }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-gray-600 text-sm mb-4 line-clamp-2">{{ tool.description }}</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span v-for="tag in tool.tags" :key="tag" class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded font-medium">
|
||||
#{{ tag }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-50 px-6 py-3 border-t border-gray-100 text-right group">
|
||||
<span class="text-blue-600 text-sm font-medium group-hover:text-blue-800 transition flex items-center justify-end">
|
||||
Open Tool <svg class="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path></svg>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loading && filteredTools.length === 0" class="text-center py-16 bg-white rounded-xl border border-dashed border-gray-300">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||
<p class="mt-4 text-gray-500 text-lg">No tools found matching "{{ searchQuery }}"</p>
|
||||
<button @click="searchQuery = ''" class="mt-2 text-blue-600 hover:text-blue-800 font-medium">Clear search</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
103
frontend/src/views/tools/Base64Encoder.vue
Normal file
103
frontend/src/views/tools/Base64Encoder.vue
Normal file
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<ToolLayout>
|
||||
<template #header>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Base64 File Encoder</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Convert files to Base64 strings for easy embedding.</p>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="max-w-5xl mx-auto space-y-6">
|
||||
|
||||
<!-- Drop Zone -->
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-lg p-12 text-center hover:border-blue-500 dark:hover:border-blue-500 transition-colors cursor-pointer"
|
||||
@click="triggerFileInput"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<div class="text-gray-600 dark:text-gray-300">
|
||||
<span class="font-medium text-blue-600 hover:text-blue-500">Upload a file</span> or drag and drop
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Any file up to 5MB</p>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" class="hidden" @change="handleFileSelect" />
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div v-if="result" class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Base64 Output</h3>
|
||||
<button
|
||||
@click="copyToClipboard"
|
||||
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
<span v-if="copied">Copied!</span>
|
||||
<span v-else>Copy</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
v-model="result"
|
||||
readonly
|
||||
rows="8"
|
||||
class="w-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm font-mono text-xs break-all"
|
||||
></textarea>
|
||||
|
||||
<div class="text-xs text-gray-500">
|
||||
Preview: <span class="font-mono">{{ result.substring(0, 50) }}...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</ToolLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import ToolLayout from '../../components/ToolLayout.vue';
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const result = ref('');
|
||||
const copied = ref(false);
|
||||
|
||||
const triggerFileInput = () => {
|
||||
fileInput.value?.click();
|
||||
};
|
||||
|
||||
const processFile = (file: File) => {
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
result.value = e.target?.result as string;
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) {
|
||||
processFile(target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
if (event.dataTransfer?.files && event.dataTransfer.files[0]) {
|
||||
processFile(event.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.value);
|
||||
copied.value = true;
|
||||
setTimeout(() => copied.value = false, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy', err);
|
||||
}
|
||||
};
|
||||
</script>
|
||||
67
frontend/src/views/tools/CronGen.vue
Normal file
67
frontend/src/views/tools/CronGen.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<template>
|
||||
<ToolLayout>
|
||||
<template #header>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Cron Schedule Generator</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Generate and explain cron expressions.</p>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Cron Expression</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="cronExpression"
|
||||
type="text"
|
||||
class="w-full px-4 py-3 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-lg"
|
||||
placeholder="*/5 * * * *"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-md border border-blue-100 dark:border-blue-800">
|
||||
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300 mb-1">Human Readable</h3>
|
||||
<p class="text-lg text-blue-900 dark:text-blue-100 font-medium">
|
||||
{{ humanReadable || 'Invalid cron expression' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Quick Presets</h3>
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<button v-for="preset in presets" :key="preset.val" @click="cronExpression = preset.val" class="px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
|
||||
{{ preset.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</ToolLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import cronstrue from 'cronstrue';
|
||||
import ToolLayout from '../../components/ToolLayout.vue';
|
||||
|
||||
const cronExpression = ref('*/5 * * * *');
|
||||
|
||||
const presets = [
|
||||
{ label: 'Every Minute', val: '* * * * *' },
|
||||
{ label: 'Every 5 Minutes', val: '*/5 * * * *' },
|
||||
{ label: 'Every Hour', val: '0 * * * *' },
|
||||
{ label: 'Every Day at Midnight', val: '0 0 * * *' },
|
||||
{ label: 'Every Week (Sun)', val: '0 0 * * 0' },
|
||||
{ label: 'Every Month (1st)', val: '0 0 1 * *' },
|
||||
];
|
||||
|
||||
const humanReadable = computed(() => {
|
||||
try {
|
||||
return cronstrue.toString(cronExpression.value);
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
92
frontend/src/views/tools/DiffViewer.vue
Normal file
92
frontend/src/views/tools/DiffViewer.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<ToolLayout>
|
||||
<template #header>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Diff Viewer</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Compare two text blocks and see differences side-by-side.</p>
|
||||
</template>
|
||||
|
||||
<template #options>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Mode</label>
|
||||
<select v-model="mode" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||
<option value="split">Split (Side-by-Side)</option>
|
||||
<option value="unified">Unified (Inline)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Language</label>
|
||||
<select v-model="language" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||
<option value="plaintext">Plain Text</option>
|
||||
<option value="json">JSON</option>
|
||||
<option value="javascript">JavaScript</option>
|
||||
<option value="typescript">TypeScript</option>
|
||||
<option value="python">Python</option>
|
||||
<option value="html">HTML</option>
|
||||
<option value="css">CSS</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="h-full flex flex-col space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4 h-1/3 min-h-[150px]">
|
||||
<div class="flex flex-col">
|
||||
<label class="text-xs font-medium text-gray-500 mb-1">Original</label>
|
||||
<textarea
|
||||
v-model="prev"
|
||||
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-xs resize-none"
|
||||
placeholder="Paste original text here..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<label class="text-xs font-medium text-gray-500 mb-1">Modified</label>
|
||||
<textarea
|
||||
v-model="current"
|
||||
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-xs resize-none"
|
||||
placeholder="Paste new text here..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden bg-white dark:bg-gray-900">
|
||||
<Diff
|
||||
:mode="mode"
|
||||
:theme="isDark ? 'dark' : 'light'"
|
||||
:language="language"
|
||||
:prev="prev"
|
||||
:current="current"
|
||||
class="h-full text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ToolLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import ToolLayout from '../../components/ToolLayout.vue';
|
||||
|
||||
const mode = ref('split');
|
||||
const language = ref('plaintext');
|
||||
const prev = ref('');
|
||||
const current = ref('');
|
||||
|
||||
// Simple dark mode detection
|
||||
const isDark = computed(() => window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Vue Diff Overrides if needed */
|
||||
.vue-diff-theme-light {
|
||||
--diff-background: #ffffff;
|
||||
--diff-text: #1f2937;
|
||||
}
|
||||
.vue-diff-theme-dark {
|
||||
--diff-background: #111827;
|
||||
--diff-text: #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
75
frontend/src/views/tools/DockerConverter.vue
Normal file
75
frontend/src/views/tools/DockerConverter.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<ToolLayout>
|
||||
<template #header>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Docker Converter</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Convert between 'docker run' commands and Docker Compose YAML.</p>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="h-full flex flex-col space-y-4">
|
||||
|
||||
<!-- Docker Run Input -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Docker Run Command</label>
|
||||
<button @click="convertToCompose" class="text-xs text-blue-600 hover:text-blue-500">Convert to Compose ↓</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="dockerRun"
|
||||
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-xs resize-none"
|
||||
placeholder="docker run -d -p 80:80 nginx"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Compose Output -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Docker Compose (YAML)</label>
|
||||
<button @click="convertToRun" class="text-xs text-blue-600 hover:text-blue-500">Convert to Run ↑</button>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="dockerCompose"
|
||||
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 font-mono text-xs resize-none"
|
||||
placeholder="version: '3.3' services:..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-red-500 text-xs">{{ error }}</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</ToolLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import composerize from 'composerize';
|
||||
import decomposerize from 'decomposerize';
|
||||
import ToolLayout from '../../components/ToolLayout.vue';
|
||||
|
||||
const dockerRun = ref('');
|
||||
const dockerCompose = ref('');
|
||||
const error = ref('');
|
||||
|
||||
const convertToCompose = () => {
|
||||
if (!dockerRun.value) return;
|
||||
try {
|
||||
dockerCompose.value = composerize(dockerRun.value);
|
||||
error.value = '';
|
||||
} catch (e: any) {
|
||||
error.value = 'Error converting to Compose: ' + e.message;
|
||||
}
|
||||
};
|
||||
|
||||
const convertToRun = () => {
|
||||
if (!dockerCompose.value) return;
|
||||
try {
|
||||
// Decomposerize types might be missing or different
|
||||
const result = decomposerize(dockerCompose.value);
|
||||
dockerRun.value = result;
|
||||
error.value = '';
|
||||
} catch (e: any) {
|
||||
error.value = 'Error converting to Run: ' + e.message;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
120
frontend/src/views/tools/JsonConverter.vue
Normal file
120
frontend/src/views/tools/JsonConverter.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<ToolLayout>
|
||||
<template #header>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">JSON / CSV / YAML Converter</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Convert data formats bi-directionally. Edit any field to update the others.</p>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="h-full grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
|
||||
<!-- JSON Input -->
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">JSON</label>
|
||||
<span v-if="jsonError" class="text-xs text-red-500">Invalid JSON</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="json"
|
||||
@input="updateFrom('json')"
|
||||
class="flex-1 w-full h-full min-h-[400px] px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-xs resize-none"
|
||||
placeholder='[{"id": 1, "name": "John"}]'
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- YAML Input -->
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">YAML</label>
|
||||
<span v-if="yamlError" class="text-xs text-red-500">Invalid YAML</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="yaml"
|
||||
@input="updateFrom('yaml')"
|
||||
class="flex-1 w-full h-full min-h-[400px] px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 font-mono text-xs resize-none"
|
||||
placeholder="- id: 1 name: John"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- CSV Input -->
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">CSV</label>
|
||||
<span v-if="csvError" class="text-xs text-red-500">Invalid CSV</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="csv"
|
||||
@input="updateFrom('csv')"
|
||||
class="flex-1 w-full h-full min-h-[400px] px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-green-500 focus:border-green-500 font-mono text-xs resize-none"
|
||||
placeholder="id,name 1,John"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</ToolLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import yamljs from 'js-yaml';
|
||||
import Papa from 'papaparse';
|
||||
import ToolLayout from '../../components/ToolLayout.vue';
|
||||
|
||||
const json = ref('');
|
||||
const yaml = ref('');
|
||||
const csv = ref('');
|
||||
|
||||
const jsonError = ref(false);
|
||||
const yamlError = ref(false);
|
||||
const csvError = ref(false);
|
||||
|
||||
const updateFrom = (source: 'json' | 'yaml' | 'csv') => {
|
||||
let data: any = null;
|
||||
|
||||
// 1. Parse Source to Object
|
||||
try {
|
||||
if (source === 'json') {
|
||||
data = JSON.parse(json.value);
|
||||
jsonError.value = false;
|
||||
} else if (source === 'yaml') {
|
||||
data = yamljs.load(yaml.value);
|
||||
yamlError.value = false;
|
||||
} else if (source === 'csv') {
|
||||
const result = Papa.parse(csv.value, { header: true, skipEmptyLines: true });
|
||||
if (result.errors.length) throw new Error('CSV Error');
|
||||
data = result.data;
|
||||
csvError.value = false;
|
||||
}
|
||||
} catch (e) {
|
||||
if (source === 'json') jsonError.value = true;
|
||||
if (source === 'yaml') yamlError.value = true;
|
||||
if (source === 'csv') csvError.value = true;
|
||||
return; // Stop if invalid
|
||||
}
|
||||
|
||||
// 2. Update Targets
|
||||
if (source !== 'json') {
|
||||
try {
|
||||
json.value = JSON.stringify(data, null, 2);
|
||||
jsonError.value = false;
|
||||
} catch { json.value = ''; }
|
||||
}
|
||||
|
||||
if (source !== 'yaml') {
|
||||
try {
|
||||
yaml.value = yamljs.dump(data);
|
||||
yamlError.value = false;
|
||||
} catch { yaml.value = ''; }
|
||||
}
|
||||
|
||||
if (source !== 'csv') {
|
||||
try {
|
||||
// Papa unparse expects array of objects
|
||||
const arrayData = Array.isArray(data) ? data : [data];
|
||||
csv.value = Papa.unparse(arrayData);
|
||||
csvError.value = false;
|
||||
} catch { csv.value = ''; }
|
||||
}
|
||||
};
|
||||
</script>
|
||||
57
frontend/src/views/tools/JsonFormatter.vue
Normal file
57
frontend/src/views/tools/JsonFormatter.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const input = ref('{"name":"Gemini","type":"AI"}')
|
||||
const output = ref('')
|
||||
const error = ref('')
|
||||
|
||||
const format = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(input.value)
|
||||
output.value = JSON.stringify(parsed, null, 2)
|
||||
error.value = ''
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
|
||||
const minify = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(input.value)
|
||||
output.value = JSON.stringify(parsed)
|
||||
error.value = ''
|
||||
} catch (e: any) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-[calc(100vh-200px)] flex flex-col">
|
||||
<div class="flex space-x-2 mb-4">
|
||||
<button @click="format" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Prettify</button>
|
||||
<button @click="minify" class="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300">Minify</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 grid grid-cols-1 md:grid-cols-2 gap-4 min-h-0">
|
||||
<div class="flex flex-col">
|
||||
<label class="mb-2 text-sm font-bold text-gray-500">Input JSON</label>
|
||||
<textarea
|
||||
v-model="input"
|
||||
class="flex-1 w-full p-4 font-mono text-sm border rounded-lg focus:ring-2 focus:ring-blue-500 resize-none"
|
||||
:class="{'border-red-500': error}"
|
||||
></textarea>
|
||||
<p v-if="error" class="text-red-500 text-sm mt-1">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col">
|
||||
<label class="mb-2 text-sm font-bold text-gray-500">Output</label>
|
||||
<textarea
|
||||
readonly
|
||||
:value="output"
|
||||
class="flex-1 w-full p-4 font-mono text-sm bg-gray-50 border rounded-lg resize-none text-gray-800"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
75
frontend/src/views/tools/JwtDebugger.vue
Normal file
75
frontend/src/views/tools/JwtDebugger.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<ToolLayout>
|
||||
<template #header>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">JWT Debugger</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Decode and inspect JSON Web Tokens (JWT) without backend verification.</p>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="space-y-6">
|
||||
<!-- Input Section -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Encoded Token
|
||||
</label>
|
||||
<textarea
|
||||
v-model="token"
|
||||
rows="3"
|
||||
class="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
||||
placeholder="Paste your JWT here (eyJhbGci...)"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div v-if="error" class="bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 p-3 rounded-md text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Output Section -->
|
||||
<div v-if="decodedHeader || decodedPayload" class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Header -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 uppercase tracking-wide">Header</h3>
|
||||
<pre class="bg-gray-50 dark:bg-gray-900 p-4 rounded-md border border-gray-200 dark:border-gray-700 overflow-auto text-xs font-mono h-64">{{ JSON.stringify(decodedHeader, null, 2) }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- Payload -->
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 uppercase tracking-wide">Payload</h3>
|
||||
<pre class="bg-gray-50 dark:bg-gray-900 p-4 rounded-md border border-gray-200 dark:border-gray-700 overflow-auto text-xs font-mono h-64">{{ JSON.stringify(decodedPayload, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ToolLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import ToolLayout from '../../components/ToolLayout.vue';
|
||||
|
||||
const token = ref('');
|
||||
const decodedHeader = ref<any>(null);
|
||||
const decodedPayload = ref<any>(null);
|
||||
const error = ref('');
|
||||
|
||||
watch(token, (newVal) => {
|
||||
if (!newVal.trim()) {
|
||||
decodedHeader.value = null;
|
||||
decodedPayload.value = null;
|
||||
error.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
decodedHeader.value = jwtDecode(newVal, { header: true });
|
||||
decodedPayload.value = jwtDecode(newVal);
|
||||
error.value = '';
|
||||
} catch (e) {
|
||||
decodedHeader.value = null;
|
||||
decodedPayload.value = null;
|
||||
error.value = 'Invalid JWT format';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
113
frontend/src/views/tools/MockDataGen.vue
Normal file
113
frontend/src/views/tools/MockDataGen.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<ToolLayout>
|
||||
<template #header>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Mock Data Generator</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Generate massive amounts of fake data for testing.</p>
|
||||
</template>
|
||||
|
||||
<template #options>
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rows Count</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="range" v-model.number="count" min="1" max="1000" class="flex-1">
|
||||
<input type="number" v-model.number="count" class="w-20 rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm sm:text-sm">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Fields</label>
|
||||
<div class="space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" v-model="fields" value="id" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">ID (UUID)</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" v-model="fields" value="firstName" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">First Name</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" v-model="fields" value="lastName" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Last Name</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" v-model="fields" value="email" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Email</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" v-model="fields" value="phone" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Phone</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" v-model="fields" value="address" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Address</span>
|
||||
</label>
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" v-model="fields" value="jobTitle" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Job Title</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="generate"
|
||||
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="copyJson"
|
||||
class="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Copy JSON
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm text-gray-500">Preview ({{ count }} items)</span>
|
||||
</div>
|
||||
<textarea
|
||||
readonly
|
||||
:value="output"
|
||||
class="flex-1 w-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm font-mono text-xs resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</template>
|
||||
</ToolLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import ToolLayout from '../../components/ToolLayout.vue';
|
||||
|
||||
const count = ref(10);
|
||||
const output = ref('');
|
||||
const fields = ref(['id', 'firstName', 'lastName', 'email']);
|
||||
|
||||
const generate = () => {
|
||||
const data = Array.from({ length: count.value }, () => {
|
||||
const item: any = {};
|
||||
if (fields.value.includes('id')) item.id = faker.string.uuid();
|
||||
if (fields.value.includes('firstName')) item.firstName = faker.person.firstName();
|
||||
if (fields.value.includes('lastName')) item.lastName = faker.person.lastName();
|
||||
if (fields.value.includes('email')) item.email = faker.internet.email();
|
||||
if (fields.value.includes('phone')) item.phone = faker.phone.number();
|
||||
if (fields.value.includes('address')) item.address = faker.location.streetAddress();
|
||||
if (fields.value.includes('jobTitle')) item.jobTitle = faker.person.jobTitle();
|
||||
return item;
|
||||
});
|
||||
output.value = JSON.stringify(data, null, 2);
|
||||
};
|
||||
|
||||
const copyJson = async () => {
|
||||
await navigator.clipboard.writeText(output.value);
|
||||
};
|
||||
|
||||
onMounted(generate);
|
||||
watch([count], generate); // Don't watch fields deeply to prevent too many re-renders
|
||||
</script>
|
||||
128
frontend/src/views/tools/PasswordGen.vue
Normal file
128
frontend/src/views/tools/PasswordGen.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { downloadCsv } from '../../utils/csvDownloader'
|
||||
|
||||
const count = ref(5)
|
||||
const length = ref(16)
|
||||
const includeUpper = ref(true)
|
||||
const includeDigits = ref(true)
|
||||
const includeSpecial = ref(true)
|
||||
|
||||
const results = ref<string[]>([])
|
||||
|
||||
// Secure random generator using Web Crypto API
|
||||
const generate = () => {
|
||||
const lower = 'abcdefghijklmnopqrstuvwxyz'
|
||||
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||||
const digits = '0123456789'
|
||||
const special = '!@#$%^&*()_+-=[]{}|;:,.<>?'
|
||||
|
||||
let chars = lower
|
||||
if (includeUpper.value) chars += upper
|
||||
if (includeDigits.value) chars += digits
|
||||
if (includeSpecial.value) chars += special
|
||||
|
||||
if (!chars) {
|
||||
alert("Please select at least one character set.")
|
||||
return
|
||||
}
|
||||
|
||||
const newPasswords: string[] = []
|
||||
|
||||
for (let i = 0; i < count.value; i++) {
|
||||
let password = ''
|
||||
const array = new Uint32Array(length.value)
|
||||
crypto.getRandomValues(array)
|
||||
|
||||
for (let j = 0; j < length.value; j++) {
|
||||
password += chars[array[j] % chars.length]
|
||||
}
|
||||
newPasswords.push(password)
|
||||
}
|
||||
|
||||
results.value = newPasswords
|
||||
}
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text)
|
||||
}
|
||||
|
||||
const downloadResults = () => {
|
||||
downloadCsv(results.value, `passwords-${new Date().getTime()}`, ['Password'])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<!-- Controls -->
|
||||
<div class="p-6 border-b border-gray-100 bg-gray-50 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase mb-2">Password Length: {{ length }}</label>
|
||||
<input type="range" v-model.number="length" min="4" max="64" class="w-full accent-blue-600">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase mb-2">Quantity: {{ count }}</label>
|
||||
<input type="range" v-model.number="count" min="1" max="50" class="w-full accent-blue-600">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase">Character Sets</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="includeUpper" class="rounded text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-sm text-gray-700">Uppercase (A-Z)</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="includeDigits" class="rounded text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-sm text-gray-700">Digits (0-9)</span>
|
||||
</label>
|
||||
<label class="flex items-center space-x-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="includeSpecial" class="rounded text-blue-600 focus:ring-blue-500">
|
||||
<span class="text-sm text-gray-700">Special Characters (!@#$...)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="p-4 bg-gray-50 border-b border-gray-100 flex justify-end space-x-3">
|
||||
<button
|
||||
v-if="results.length > 0"
|
||||
@click="downloadResults"
|
||||
class="px-4 py-2 bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 rounded-lg font-medium transition flex items-center shadow-sm"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
|
||||
Download CSV
|
||||
</button>
|
||||
<button
|
||||
@click="generate"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-bold shadow-sm transition flex items-center"
|
||||
>
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div class="p-0">
|
||||
<ul v-if="results.length > 0" class="divide-y divide-gray-100">
|
||||
<li v-for="(pwd, idx) in results" :key="idx" class="p-4 font-mono text-gray-700 hover:bg-gray-50 flex justify-between items-center group">
|
||||
<span class="break-all mr-4">{{ pwd }}</span>
|
||||
<button
|
||||
@click="copyToClipboard(pwd)"
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-blue-600 transition"
|
||||
title="Copy"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="p-12 text-center text-gray-400">
|
||||
Adjust settings and click generate to create secure passwords locally.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
233
frontend/src/views/tools/PngCompressor.vue
Normal file
233
frontend/src/views/tools/PngCompressor.vue
Normal file
@@ -0,0 +1,233 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface CompressionResult {
|
||||
filename: string
|
||||
original_size: number
|
||||
compressed_size: number
|
||||
ratio: number
|
||||
status: string
|
||||
}
|
||||
|
||||
const files = ref<File[]>([])
|
||||
const isDragging = ref(false)
|
||||
const uploading = ref(false)
|
||||
const results = ref<CompressionResult[]>([])
|
||||
const downloadUrl = ref('')
|
||||
const downloadFilename = ref('')
|
||||
const jobId = ref('')
|
||||
|
||||
const totalSavings = computed(() => {
|
||||
if (results.value.length === 0) return 0
|
||||
const orig = results.value.reduce((acc, r) => acc + r.original_size, 0)
|
||||
const comp = results.value.reduce((acc, r) => acc + r.compressed_size, 0)
|
||||
return Math.round(((orig - comp) / orig) * 100)
|
||||
})
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
}
|
||||
|
||||
const onDrop = (e: DragEvent) => {
|
||||
isDragging.value = false
|
||||
const droppedFiles = e.dataTransfer?.files
|
||||
if (droppedFiles) {
|
||||
addFiles(droppedFiles)
|
||||
}
|
||||
}
|
||||
|
||||
const onFileSelect = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
if (target.files) {
|
||||
addFiles(target.files)
|
||||
}
|
||||
}
|
||||
|
||||
const addFiles = (fileList: FileList) => {
|
||||
// Reset previous results if adding new files
|
||||
if (results.value.length > 0) {
|
||||
results.value = []
|
||||
downloadUrl.value = ''
|
||||
files.value = []
|
||||
jobId.value = ''
|
||||
}
|
||||
|
||||
for (let i = 0; i < fileList.length; i++) {
|
||||
const file = fileList[i]
|
||||
if (file.type === 'image/png') {
|
||||
files.value.push(file)
|
||||
} else {
|
||||
alert(`Skipped non-PNG file: ${file.name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
files.value.splice(index, 1)
|
||||
}
|
||||
|
||||
const processFiles = async () => {
|
||||
if (files.value.length === 0) return
|
||||
|
||||
uploading.value = true
|
||||
const formData = new FormData()
|
||||
files.value.forEach(file => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/tools/png-compress', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
if (!res.ok) throw new Error('Compression failed')
|
||||
|
||||
const data = await res.json()
|
||||
results.value = data.stats
|
||||
jobId.value = data.job_id
|
||||
downloadFilename.value = data.zip_filename
|
||||
downloadUrl.value = `/api/tools/download/${data.zip_filename}`
|
||||
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert('Failed to compress images.')
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="p-6 border-b border-gray-100">
|
||||
<h2 class="text-xl font-bold text-gray-800">PNG Compressor</h2>
|
||||
<p class="text-gray-500 text-sm mt-1">
|
||||
Compress PNGs using <code>pngquant</code>. Reduces file size by 60-80% with minimal quality loss.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Drag & Drop Area -->
|
||||
<div
|
||||
v-if="results.length === 0"
|
||||
@dragover.prevent="isDragging = true"
|
||||
@dragleave.prevent="isDragging = false"
|
||||
@drop.prevent="onDrop"
|
||||
class="border-2 border-dashed rounded-xl p-8 text-center transition-colors duration-200 cursor-pointer"
|
||||
:class="isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'"
|
||||
@click="$refs.fileInput.click()"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
multiple
|
||||
accept="image/png"
|
||||
class="hidden"
|
||||
@change="onFileSelect"
|
||||
>
|
||||
<div class="flex flex-col items-center justify-center space-y-3">
|
||||
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg>
|
||||
<div class="text-gray-600">
|
||||
<span class="font-medium text-blue-600 hover:text-blue-500">Click to upload</span>
|
||||
or drag and drop
|
||||
</div>
|
||||
<p class="text-xs text-gray-400">PNG files only</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File List (Pending) -->
|
||||
<div v-if="files.length > 0 && results.length === 0" class="mt-6">
|
||||
<h3 class="text-sm font-bold text-gray-500 uppercase mb-3">Selected Files ({{ files.length }})</h3>
|
||||
<ul class="divide-y divide-gray-100 border rounded-lg overflow-hidden">
|
||||
<li v-for="(file, idx) in files" :key="idx" class="p-3 bg-gray-50 flex justify-between items-center text-sm">
|
||||
<span class="truncate">{{ file.name }}</span>
|
||||
<div class="flex items-center space-x-4">
|
||||
<span class="text-gray-400">{{ formatBytes(file.size) }}</span>
|
||||
<button @click.stop="removeFile(idx)" class="text-red-400 hover:text-red-600">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 flex justify-end">
|
||||
<button
|
||||
@click="processFiles"
|
||||
:disabled="uploading"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-bold shadow-sm transition disabled:opacity-50 flex items-center"
|
||||
>
|
||||
<svg v-if="uploading" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
||||
{{ uploading ? 'Compressing...' : 'Upload & Process' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results -->
|
||||
<div v-if="results.length > 0" class="mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-bold text-gray-800">Results</h3>
|
||||
<span class="text-green-600 font-bold bg-green-50 px-3 py-1 rounded-full text-sm">
|
||||
Total Savings: {{ totalSavings }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm whitespace-nowrap">
|
||||
<thead class="uppercase tracking-wider border-b-2 border-gray-100 bg-gray-50 text-gray-500 font-semibold">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3">Filename</th>
|
||||
<th scope="col" class="px-4 py-3 text-right">Original</th>
|
||||
<th scope="col" class="px-4 py-3 text-right">Compressed</th>
|
||||
<th scope="col" class="px-4 py-3 text-right">Ratio</th>
|
||||
<th scope="col" class="px-4 py-3 text-center">Status</th>
|
||||
<th scope="col" class="px-4 py-3 text-center">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
<tr v-for="(res, idx) in results" :key="idx" class="hover:bg-gray-50">
|
||||
<td class="px-4 py-3 font-medium text-gray-900">{{ res.filename }}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-500">{{ formatBytes(res.original_size) }}</td>
|
||||
<td class="px-4 py-3 text-right text-gray-900 font-bold">{{ formatBytes(res.compressed_size) }}</td>
|
||||
<td class="px-4 py-3 text-right text-green-600">{{ res.ratio }}%</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span v-if="res.status === 'Success'" class="text-green-600">✅</span>
|
||||
<span v-else class="text-gray-400 text-xs">{{ res.status }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<a
|
||||
v-if="res.status === 'Success'"
|
||||
:href="`/api/tools/download-single/${jobId}/${res.filename}`"
|
||||
target="_blank"
|
||||
class="text-blue-600 hover:text-blue-800 font-medium text-xs border border-blue-200 bg-blue-50 px-2 py-1 rounded hover:bg-blue-100 transition"
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex justify-between items-center bg-gray-50 p-4 rounded-lg">
|
||||
<button @click="files = []; results = []; jobId = ''" class="text-gray-500 hover:text-gray-700 font-medium text-sm">
|
||||
← Compress More
|
||||
</button>
|
||||
<a
|
||||
:href="downloadUrl"
|
||||
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-bold shadow-lg shadow-green-200 transition flex items-center"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
|
||||
Download All (ZIP)
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
109
frontend/src/views/tools/QrGenerator.vue
Normal file
109
frontend/src/views/tools/QrGenerator.vue
Normal file
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<ToolLayout>
|
||||
<template #header>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">QR Code Generator</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Generate QR codes instantly from text or URLs.</p>
|
||||
</template>
|
||||
|
||||
<template #options>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Text / URL</label>
|
||||
<textarea
|
||||
v-model="text"
|
||||
rows="3"
|
||||
class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
|
||||
placeholder="https://example.com"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Error Correction</label>
|
||||
<select v-model="errorLevel" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||
<option value="L">Low (7%)</option>
|
||||
<option value="M">Medium (15%)</option>
|
||||
<option value="Q">Quartile (25%)</option>
|
||||
<option value="H">High (30%)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Scale (Size)</label>
|
||||
<input
|
||||
type="range"
|
||||
v-model.number="scale"
|
||||
min="2"
|
||||
max="10"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="text-xs text-gray-500 text-right">{{ scale }}x</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="qrDataUrl"
|
||||
@click="downloadQr"
|
||||
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Download PNG
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-black/20 rounded-lg border-2 border-dashed border-gray-200 dark:border-gray-700">
|
||||
<div v-if="text" class="text-center">
|
||||
<img :src="qrDataUrl" alt="QR Code" class="mx-auto shadow-lg border-4 border-white" />
|
||||
<p class="mt-4 text-sm text-gray-500 break-all max-w-md">{{ text }}</p>
|
||||
</div>
|
||||
<div v-else class="text-center text-gray-400">
|
||||
<p>Enter text to generate QR code</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ToolLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
import QRCode from 'qrcode';
|
||||
import ToolLayout from '../../components/ToolLayout.vue';
|
||||
|
||||
const text = ref('https://example.com');
|
||||
const errorLevel = ref('M');
|
||||
const scale = ref(6);
|
||||
const qrDataUrl = ref('');
|
||||
|
||||
const generate = async () => {
|
||||
if (!text.value) {
|
||||
qrDataUrl.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
qrDataUrl.value = await QRCode.toDataURL(text.value, {
|
||||
errorCorrectionLevel: errorLevel.value as any,
|
||||
width: undefined,
|
||||
scale: scale.value,
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff'
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
const downloadQr = () => {
|
||||
const link = document.createElement('a');
|
||||
link.download = 'qrcode.png';
|
||||
link.href = qrDataUrl.value;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
watch([text, errorLevel, scale], generate);
|
||||
onMounted(generate);
|
||||
</script>
|
||||
98
frontend/src/views/tools/RegexTester.vue
Normal file
98
frontend/src/views/tools/RegexTester.vue
Normal file
@@ -0,0 +1,98 @@
|
||||
<template>
|
||||
<ToolLayout>
|
||||
<template #header>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Regex Tester</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Test and debug regular expressions.</p>
|
||||
</template>
|
||||
|
||||
<template #options>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Pattern</label>
|
||||
<input
|
||||
v-model="pattern"
|
||||
type="text"
|
||||
class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono"
|
||||
placeholder="[a-z]+"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Flags</label>
|
||||
<div class="flex space-x-2">
|
||||
<label class="inline-flex items-center text-xs">
|
||||
<input type="checkbox" v-model="flagGlobal" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
|
||||
<span class="ml-1 text-gray-700 dark:text-gray-300">g</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center text-xs">
|
||||
<input type="checkbox" v-model="flagInsensitive" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
|
||||
<span class="ml-1 text-gray-700 dark:text-gray-300">i</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center text-xs">
|
||||
<input type="checkbox" v-model="flagMultiline" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
|
||||
<span class="ml-1 text-gray-700 dark:text-gray-300">m</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-xs text-red-500">{{ error }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="h-full flex flex-col space-y-4">
|
||||
<div class="flex-1 flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Test String</label>
|
||||
<textarea
|
||||
v-model="text"
|
||||
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-sm resize-none"
|
||||
placeholder="Type text here to test against the regex..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="h-1/3 bg-gray-50 dark:bg-gray-900 p-4 rounded-md border border-gray-200 dark:border-gray-700 overflow-auto">
|
||||
<h3 class="text-xs font-medium text-gray-500 mb-2 uppercase">Matches</h3>
|
||||
<div v-if="matches.length === 0" class="text-sm text-gray-400">No matches found.</div>
|
||||
<ul v-else class="space-y-1">
|
||||
<li v-for="(match, idx) in matches" :key="idx" class="text-sm font-mono text-gray-800 dark:text-gray-200">
|
||||
<span class="bg-blue-100 dark:bg-blue-900/50 px-1 rounded">Match {{ idx + 1 }}:</span> {{ match }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ToolLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import ToolLayout from '../../components/ToolLayout.vue';
|
||||
|
||||
const pattern = ref('');
|
||||
const text = ref('');
|
||||
const flagGlobal = ref(true);
|
||||
const flagInsensitive = ref(false);
|
||||
const flagMultiline = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
const matches = computed(() => {
|
||||
if (!pattern.value) return [];
|
||||
try {
|
||||
let flags = '';
|
||||
if (flagGlobal.value) flags += 'g';
|
||||
if (flagInsensitive.value) flags += 'i';
|
||||
if (flagMultiline.value) flags += 'm';
|
||||
|
||||
const regex = new RegExp(pattern.value, flags);
|
||||
error.value = '';
|
||||
|
||||
if (!text.value) return [];
|
||||
|
||||
const found = text.value.match(regex);
|
||||
return found ? Array.from(found) : [];
|
||||
} catch (e: any) {
|
||||
error.value = e.message;
|
||||
return [];
|
||||
}
|
||||
});
|
||||
</script>
|
||||
91
frontend/src/views/tools/SqlFormatter.vue
Normal file
91
frontend/src/views/tools/SqlFormatter.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<template>
|
||||
<ToolLayout>
|
||||
<template #header>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">SQL Formatter</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Beautify and standardize your SQL queries.</p>
|
||||
</template>
|
||||
|
||||
<template #options>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Language</label>
|
||||
<select v-model="language" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||
<option value="sql">Standard SQL</option>
|
||||
<option value="mysql">MySQL</option>
|
||||
<option value="postgresql">PostgreSQL</option>
|
||||
<option value="plsql">PL/SQL</option>
|
||||
<option value="tsql">Transact-SQL</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Indent Style</label>
|
||||
<select v-model="indent" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
|
||||
<option value=" ">2 Spaces</option>
|
||||
<option value=" ">4 Spaces</option>
|
||||
<option value="\t">Tab</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="formatSql"
|
||||
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Format SQL
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="h-full flex flex-col space-y-4">
|
||||
<div class="flex-1 flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Input</label>
|
||||
<textarea
|
||||
v-model="inputSql"
|
||||
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-sm resize-none"
|
||||
placeholder="SELECT * FROM table WHERE id = 1"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Output</label>
|
||||
<div class="relative flex-1">
|
||||
<textarea
|
||||
readonly
|
||||
:value="outputSql"
|
||||
class="absolute inset-0 w-full h-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm font-mono text-sm resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</ToolLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { format } from 'sql-formatter';
|
||||
import ToolLayout from '../../components/ToolLayout.vue';
|
||||
|
||||
const inputSql = ref('');
|
||||
const outputSql = ref('');
|
||||
const language = ref('sql');
|
||||
const indent = ref(' ');
|
||||
|
||||
const formatSql = () => {
|
||||
if (!inputSql.value) {
|
||||
outputSql.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
outputSql.value = format(inputSql.value, {
|
||||
language: language.value as any,
|
||||
tabWidth: indent.value === '\t' ? undefined : indent.value.length,
|
||||
useTabs: indent.value === '\t',
|
||||
});
|
||||
} catch (e: any) {
|
||||
outputSql.value = `Error: ${e.message}`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
83
frontend/src/views/tools/SvgOptimizer.vue
Normal file
83
frontend/src/views/tools/SvgOptimizer.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<ToolLayout>
|
||||
<template #header>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">SVG Optimizer</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Minify SVG code using SVGO.</p>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="h-full flex flex-col space-y-4">
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 h-full">
|
||||
<!-- Input -->
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Input SVG</label>
|
||||
<span class="text-xs text-gray-500">{{ inputSize }} bytes</span>
|
||||
</div>
|
||||
<textarea
|
||||
v-model="inputSvg"
|
||||
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-xs resize-none"
|
||||
placeholder="<svg>...</svg>"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Output -->
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Optimized SVG</label>
|
||||
<span class="text-xs text-green-600 font-medium" v-if="saved > 0">-{{ saved }}% ({{ outputSize }} bytes)</span>
|
||||
</div>
|
||||
<textarea
|
||||
readonly
|
||||
:value="outputSvg"
|
||||
class="flex-1 w-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm font-mono text-xs resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="optimize"
|
||||
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Optimize SVG
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</ToolLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { optimize as svgoOptimize } from 'svgo/browser';
|
||||
import ToolLayout from '../../components/ToolLayout.vue';
|
||||
|
||||
const inputSvg = ref('');
|
||||
const outputSvg = ref('');
|
||||
|
||||
const inputSize = computed(() => new Blob([inputSvg.value]).size);
|
||||
const outputSize = computed(() => new Blob([outputSvg.value]).size);
|
||||
const saved = computed(() => {
|
||||
if (inputSize.value === 0) return 0;
|
||||
return Math.round(((inputSize.value - outputSize.value) / inputSize.value) * 100);
|
||||
});
|
||||
|
||||
const optimize = () => {
|
||||
if (!inputSvg.value) return;
|
||||
try {
|
||||
const result = svgoOptimize(inputSvg.value, {
|
||||
multipass: true,
|
||||
plugins: [
|
||||
'preset-default',
|
||||
'removeDimensions',
|
||||
'convertStyleToAttrs',
|
||||
],
|
||||
});
|
||||
outputSvg.value = result.data;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
outputSvg.value = 'Error optimizing SVG';
|
||||
}
|
||||
};
|
||||
</script>
|
||||
79
frontend/src/views/tools/UrlParser.vue
Normal file
79
frontend/src/views/tools/UrlParser.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const input = ref('https://example.com/search?q=hello world')
|
||||
const mode = ref<'encode' | 'decode'>('encode')
|
||||
|
||||
const output = computed(() => {
|
||||
if (!input.value) return ''
|
||||
try {
|
||||
return mode.value === 'encode'
|
||||
? encodeURIComponent(input.value)
|
||||
: decodeURIComponent(input.value)
|
||||
} catch (e) {
|
||||
return 'Error: Invalid URI'
|
||||
}
|
||||
})
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(output.value)
|
||||
alert('Copied!')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
|
||||
<div class="flex items-center space-x-4 mb-6">
|
||||
<button
|
||||
@click="mode = 'encode'"
|
||||
class="px-4 py-2 rounded-lg font-medium transition"
|
||||
:class="mode === 'encode' ? 'bg-blue-600 text-white shadow-lg shadow-blue-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
>
|
||||
Encode
|
||||
</button>
|
||||
<button
|
||||
@click="mode = 'decode'"
|
||||
class="px-4 py-2 rounded-lg font-medium transition"
|
||||
:class="mode === 'decode' ? 'bg-blue-600 text-white shadow-lg shadow-blue-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
|
||||
>
|
||||
Decode
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Input</label>
|
||||
<textarea
|
||||
v-model="input"
|
||||
rows="4"
|
||||
class="w-full rounded-lg border-gray-300 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<svg class="w-6 h-6 text-gray-400 rotate-90 md:rotate-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"></path></svg>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">Output</label>
|
||||
<div class="relative">
|
||||
<textarea
|
||||
readonly
|
||||
:value="output"
|
||||
rows="4"
|
||||
class="w-full rounded-lg border-gray-300 border p-3 bg-gray-50 font-mono text-sm text-gray-800"
|
||||
></textarea>
|
||||
<button
|
||||
@click="copyToClipboard"
|
||||
class="absolute top-2 right-2 p-2 bg-white rounded border border-gray-200 hover:bg-gray-50 text-gray-500"
|
||||
title="Copy"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
93
frontend/src/views/tools/UuidGen.vue
Normal file
93
frontend/src/views/tools/UuidGen.vue
Normal file
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { v1 as uuidv1, v4 as uuidv4 } from 'uuid'
|
||||
import { downloadCsv } from '../../utils/csvDownloader'
|
||||
|
||||
const count = ref(5)
|
||||
const version = ref(4)
|
||||
const results = ref<string[]>([])
|
||||
|
||||
const generate = () => {
|
||||
const newUuids: string[] = []
|
||||
|
||||
for (let i = 0; i < count.value; i++) {
|
||||
if (version.value === 1) {
|
||||
newUuids.push(uuidv1())
|
||||
} else {
|
||||
// Use native crypto API if available for v4, fallback to library
|
||||
if (typeof crypto.randomUUID === 'function') {
|
||||
newUuids.push(crypto.randomUUID())
|
||||
} else {
|
||||
newUuids.push(uuidv4())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results.value = newUuids
|
||||
}
|
||||
|
||||
const downloadResults = () => {
|
||||
downloadCsv(results.value, `uuids-v${version.value}-${new Date().getTime()}`, ['UUID'])
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
||||
<div class="p-6 border-b border-gray-100 bg-gray-50">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Quantity</label>
|
||||
<input
|
||||
v-model.number="count"
|
||||
type="number"
|
||||
min="1"
|
||||
max="50"
|
||||
class="w-24 rounded border-gray-300 p-2"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Version</label>
|
||||
<select v-model.number="version" class="w-32 rounded border-gray-300 p-2">
|
||||
<option :value="4">UUID v4 (Random)</option>
|
||||
<option :value="1">UUID v1 (Time)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="ml-auto flex space-x-2">
|
||||
<button
|
||||
v-if="results.length > 0"
|
||||
@click="downloadResults"
|
||||
class="px-4 py-2 bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 rounded font-medium transition flex items-center"
|
||||
>
|
||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
|
||||
CSV
|
||||
</button>
|
||||
<button
|
||||
@click="generate"
|
||||
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded font-medium transition"
|
||||
>
|
||||
Generate UUIDs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-0">
|
||||
<ul v-if="results.length > 0" class="divide-y divide-gray-100">
|
||||
<li v-for="(uuid, idx) in results" :key="idx" class="p-4 font-mono text-gray-700 hover:bg-gray-50 flex justify-between items-center group">
|
||||
<span>{{ uuid }}</span>
|
||||
<button
|
||||
@click="navigator.clipboard.writeText(uuid)"
|
||||
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-blue-600 transition"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="p-12 text-center text-gray-400">
|
||||
Press generate to create UUIDs using Python backend.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
111
frontend/src/views/tools/VideoToGif.vue
Normal file
111
frontend/src/views/tools/VideoToGif.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<ToolLayout>
|
||||
<template #header>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Video to GIF</h1>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Convert short video clips to GIF format (Server-side).</p>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div class="max-w-4xl mx-auto space-y-8">
|
||||
|
||||
<!-- Upload -->
|
||||
<div
|
||||
class="border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-lg p-12 text-center hover:border-blue-500 dark:hover:border-blue-500 transition-colors cursor-pointer"
|
||||
@click="triggerFileInput"
|
||||
@drop.prevent="handleDrop"
|
||||
@dragover.prevent
|
||||
>
|
||||
<div v-if="loading" class="space-y-4">
|
||||
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600 mx-auto"></div>
|
||||
<p class="text-gray-600 dark:text-gray-300">Converting... This may take a moment.</p>
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<div class="text-gray-600 dark:text-gray-300">
|
||||
<span class="font-medium text-blue-600 hover:text-blue-500">Upload Video</span> (MP4, MOV)
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Max 10MB recommended</p>
|
||||
</div>
|
||||
<input ref="fileInput" type="file" accept="video/*" class="hidden" @change="handleFileSelect" />
|
||||
</div>
|
||||
|
||||
<!-- Result -->
|
||||
<div v-if="gifUrl" class="text-center space-y-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Conversion Complete!</h3>
|
||||
<img :src="gifUrl" alt="Converted GIF" class="mx-auto rounded shadow-lg max-h-96" />
|
||||
<a
|
||||
:href="gifUrl"
|
||||
download="converted.gif"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Download GIF
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="bg-red-50 dark:bg-red-900/20 p-4 rounded text-red-600 dark:text-red-400 text-sm text-center">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</ToolLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import axios from 'axios';
|
||||
import ToolLayout from '../../components/ToolLayout.vue';
|
||||
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const loading = ref(false);
|
||||
const gifUrl = ref('');
|
||||
const error = ref('');
|
||||
|
||||
const triggerFileInput = () => {
|
||||
if (!loading.value) fileInput.value?.click();
|
||||
};
|
||||
|
||||
const handleFileSelect = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.files && target.files[0]) {
|
||||
uploadAndConvert(target.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (event: DragEvent) => {
|
||||
if (loading.value) return;
|
||||
if (event.dataTransfer?.files && event.dataTransfer.files[0]) {
|
||||
uploadAndConvert(event.dataTransfer.files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAndConvert = async (file: File) => {
|
||||
if (!file.type.startsWith('video/')) {
|
||||
error.value = 'Please upload a valid video file.';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
gifUrl.value = '';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await axios.post('/api/tools/video/gif', formData, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const blob = new Blob([response.data], { type: 'image/gif' });
|
||||
gifUrl.value = URL.createObjectURL(blob);
|
||||
} catch (e: any) {
|
||||
error.value = 'Conversion failed. The server might be busy or the format is unsupported.';
|
||||
console.error(e);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
22
frontend/vite.config.ts
Normal file
22
frontend/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
// 개발 중 API 요청을 백엔드(FastAPI)로 프록시
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user