Initialize project

This commit is contained in:
woozu.shin
2026-01-28 15:33:47 +09:00
commit 3fe5424732
43 changed files with 6108 additions and 0 deletions

11
.gemini_instructions.md Normal file
View 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
View 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/

File diff suppressed because one or more lines are too long

48
AGENTS.md Normal file
View 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
View 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
View 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.

View 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

View 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
View 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)

View 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)

View 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.

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

40
frontend/package.json Normal file
View 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
View 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">
&copy; 2026 Web Utils. Powered by Vue 3 & FastAPI.
</div>
</footer>
</div>
</template>

View 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>

View 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
View 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')

View 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
View 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;
}
}

View 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
}
};

View 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>

View 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>

View 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>

View 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>

View 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'&#10;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>

View 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&#10; 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&#10;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>

View 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>

View 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>

View 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>

View 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>

View 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">
&larr; 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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
View 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,
}
}
}
})