commit 924f57fe032c15daa5bff7ba5e464ef71f20c919 Author: Joao Carreira Date: Tue Sep 8 10:22:01 2026 +0100 feat: inital implementation diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..8ec3360 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.env +.env.* +!.env.example +node_modules +dist +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..5eb7f1b --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +# Docker Compose configuration only. These values are not application secrets. +APP_PORT=8080 + +# Host directory mounted read-only as /overlays in the nginx container. +# Edit its images and overlays.json without rebuilding the application image. +OVERLAYS_DIR=./public/overlays diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..13ecb5a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.env +.env.* +!.env.example +node_modules +dist +*.log +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..f785783 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,823 @@ +# AGENTS.md + +## Purpose + +This repository contains **Local Image Editor**, a small privacy-friendly browser application for placing one or more image overlays on top of a user-supplied background image and exporting the result at the background image's original resolution. + +The application is intentionally simple: + +```text +Upload background + ↓ +Add predefined or custom overlays + ↓ +Drag / resize / fine-tune coordinates + ↓ +Download PNG or JPEG +``` + +This file defines the expectations and constraints for coding agents working in this repository. + +Read **README.md** and **PLAN.md** before making non-trivial changes. + +--- + +## Core principles + +These are non-negotiable unless the user explicitly changes the product requirements. + +1. **User images stay local to the browser.** + - Do not upload user images to a server. + - Do not add analytics that capture image content. + - Do not add cloud storage or third-party image processing. + - Do not send user files to external APIs. + +2. **The original image pixel dimensions are the canonical coordinate system.** + - X, Y, width, height, bounds, quick positioning, and export must all be expressed in source-image pixels. + - Browser/display size must never change logical object coordinates. + +3. **Do not use a full-resolution interactive Fabric canvas for normal editing.** + - The current editor intentionally uses a smaller backing store plus a Fabric viewport transform. + - This is important for mobile memory usage and large phone photos. + - Full source resolution should only be used transiently when producing the final export. + +4. **Export must preserve the exact source dimensions.** + - A 4032×3024 background must produce a 4032×3024 export. + - UI scaling must not affect export resolution or object placement. + +5. **The project is frontend-only.** + - Do not introduce a backend, database, authentication, user accounts, or server-side image processing unless explicitly requested. + +6. **The repository is public.** + - Never commit secrets or private infrastructure data. + - Do not assume obscurity, minification, Docker images, or frontend environment variables provide secrecy. + +7. **Keep the product simple.** + - Prefer a small, understandable implementation over speculative abstractions or feature creep. + +--- + +## Technology baseline + +Use the existing stack unless the task specifically requires a change: + +- Vue 3 +- Vite +- TypeScript +- Fabric.js +- Node.js 22+ +- nginx +- Docker / Docker Compose + +Do not downgrade Fabric below the security baseline already enforced by the repository checks. + +Avoid adding dependencies when the browser, Vue, Fabric, or a small local helper can solve the problem cleanly. + +If adding or changing dependencies: + +- update `package.json`; +- update and commit `package-lock.json`; +- run the full checks; +- inspect the dependency for current security issues; +- avoid packages that add remote runtime dependencies or tracking. + +--- + +## Repository map + +Important files and responsibilities: + +```text +src/ +├── App.vue +├── main.ts +├── components/ +│ ├── BackgroundUpload.vue +│ ├── ExportControls.vue +│ ├── ImageEditor.vue +│ ├── OverlayControls.vue +│ └── OverlayPicker.vue +├── composables/ +│ ├── useFabricEditor.ts +│ └── useLocale.ts +├── types/ +│ └── editor.ts +├── utils/ +│ ├── editorMath.ts +│ ├── imageFiles.ts +│ └── overlayConfig.ts +└── styles/ + └── main.css + +public/ +└── overlays/ + ├── overlays.json + └── + +tests/ +├── editorMath.test.ts +└── overlayConfig.test.ts + +scripts/ +├── quality-check.mjs +└── security-check.mjs +``` + +### Ownership boundaries + +Keep these boundaries intact: + +- **`useFabricEditor.ts`** owns Fabric.js lifecycle and canvas manipulation. +- **`editorMath.ts`** contains pure coordinate, size, bounds, and positioning logic. +- **`overlayConfig.ts`** validates the runtime overlay library. +- Vue components should primarily handle UI, props/events, and presentation. +- Do not spread direct Fabric manipulation throughout multiple components. + +If new Fabric behavior is needed, prefer adding it to `useFabricEditor.ts` or a focused utility rather than manipulating the canvas directly from a UI component. + +--- + +## Development commands + +Install dependencies: + +```sh +npm ci +``` + +Run locally: + +```sh +npm run dev +``` + +Type check: + +```sh +npm run type-check +``` + +Run tests: + +```sh +npm test +``` + +Repository quality checks: + +```sh +npm run lint +``` + +Security checks: + +```sh +npm run security-check +``` + +Run all non-build checks: + +```sh +npm run check +``` + +Production build: + +```sh +npm run build +``` + +Production dependency audit: + +```sh +npm audit --omit=dev --omit=optional --audit-level=high +``` + +Docker: + +```sh +docker compose up --build +``` + +Before considering a code change complete, normally run: + +```sh +npm run check +npm run build +``` + +If dependencies changed, also run the production dependency audit. + +If Docker/nginx files changed, build and validate the container as well. + +--- + +## Required agent workflow + +For non-trivial tasks: + +1. Read the relevant implementation before editing. +2. Read related tests. +3. Check `README.md` and `PLAN.md` for product constraints. +4. Identify the smallest set of files that should own the change. +5. Preserve the coordinate/rendering model described below. +6. Add or update tests for logic changes. +7. Run `npm run check`. +8. Run `npm run build`. +9. Review the diff for accidental scope expansion, secrets, generated files, and documentation drift. +10. Update `README.md` and/or `PLAN.md` when behavior, deployment, architecture, supported formats, or security assumptions change. + +Do not claim a check passed unless it was actually run. + +If the local environment prevents a check from running, state exactly which check could not be completed and why. + +--- + +## Coordinate and rendering model + +This is the most important technical invariant in the project. + +Assume a source image is: + +```text +4032 × 3024 +``` + +while the editor is shown at: + +```text +900 × 675 +``` + +Objects still use logical coordinates such as: + +```text +x = 3200 +y = 80 +width = 700 +``` + +Those values are **source-image pixels**, not screen pixels. + +### Interactive editing + +The interactive Fabric canvas should use a display-sized backing store and a viewport transform: + +```text +logical scene: 4032 × 3024 +interactive backing: 900 × 675 +viewport scale: 900 / 4032 +``` + +Do not regress to allocating one or more 4032×3024 Fabric canvases during normal editing. + +### Browser resize + +Resizing the browser or editor container may change the viewport/display scale. + +It must **not** change: + +- object X/Y; +- object logical width/height; +- background logical dimensions; +- exported coordinates. + +Use container-based resize behavior. Prefer `ResizeObserver` for editor layout changes rather than relying only on `window.resize`. + +### Export + +Export must temporarily render at source resolution and then restore the interactive editor state. + +Any export implementation must: + +- preserve the original background dimensions; +- preserve overlay transforms; +- hide Fabric controls/selection from output; +- restore selection and viewport state in `finally`; +- handle allocation/encoding failures gracefully; +- avoid leaving the editor in a modified state after failure. + +Prefer asynchronous Blob-based downloads over large synchronous Base64 URLs. + +For JPEG export, explicitly flatten transparency onto white unless product requirements change. + +--- + +## Fabric.js rules + +### Background + +The background image: + +- is not a normal editable overlay; +- must be non-selectable; +- must be non-evented; +- defines the logical canvas dimensions. + +Replacing the background clears the current composition, so preserve the existing user confirmation behavior whenever overlays exist. + +### Overlays + +Overlay objects are independent Fabric image objects. + +Each must: + +- have a stable internal ID; +- have a human-readable name; +- be independently selectable; +- support drag and uniform resize; +- preserve aspect ratio; +- stay within background bounds; +- be removable. + +For the MVP: + +- rotation is disabled; +- skewing is disabled; +- flipping should not occur; +- edge-only non-uniform scaling controls should remain disabled. + +Do not silently re-enable distortion controls. + +### Transform updates + +When moving or resizing an overlay: + +- enforce uniform scale; +- clamp it into the logical background bounds; +- call the relevant Fabric coordinate update methods; +- publish the updated selection values; +- request rendering only as needed. + +Do not assume `object.width` is the rendered width. Fabric source dimensions and transformed/scaled dimensions are different concepts. + +### Async image loading + +Background and overlay decoding are asynchronous. + +Preserve the generation/invalidation logic so stale image loads cannot overwrite newer user actions. + +A background replacement must invalidate overlay loads started for the previous background. + +If adding new asynchronous editor operations, design them so the latest user action wins. + +### Fabric lifecycle + +Use one controlled Fabric canvas per editor instance. + +On component teardown: + +- remove/clean listeners as needed; +- dispose the Fabric canvas; +- avoid stale references. + +Do not repeatedly recreate Fabric canvases during normal Vue updates. + +--- + +## Bounds and sizing + +Pure transform rules belong in `src/utils/editorMath.ts` where possible. + +Overlay bounds should normally satisfy: + +```text +x >= 0 +y >= 0 +x + width <= backgroundWidth +y + height <= backgroundHeight +``` + +Manual numeric inputs, drag, resize, and quick-position actions must all obey the same rules. + +Do not implement separate inconsistent bounds algorithms in different components. + +Default overlay placement is intentionally resolution-independent: + +- default width is proportional to background width; +- margin is proportional to image dimensions; +- default position is near the top-right corner. + +If these defaults change, add/update tests. + +--- + +## Supported image formats + +The current supported formats are: + +- PNG +- JPEG +- WebP + +Keep validation centralized in `src/utils/imageFiles.ts`. + +Do not add SVG support casually. + +If SVG support is explicitly requested, treat it as a deliberate feature: +- review current Fabric SVG security considerations; +- update validation; +- add tests; +- update CSP assumptions if relevant; +- update README/PLAN. + +Do not trust only file extensions for user uploads. + +--- + +## Object URLs and browser resources + +Custom background and overlay files remain local. + +If using `URL.createObjectURL()`: + +- revoke URLs after decoding or when no longer needed; +- do not leak object URLs across repeated uploads; +- do not revoke an object URL before Fabric/image decoding has completed. + +Avoid retaining large decoded images or canvases unnecessarily. + +Performance on mobile Safari and large phone photos is a first-class concern. + +--- + +## Overlay library + +Runtime predefined overlays live under: + +```text +public/overlays/ +``` + +The library is configured by: + +```text +public/overlays/overlays.json +``` + +Example: + +```json +{ + "id": "crr-logo", + "name": "CRR Logo", + "file": "crr-logo.png" +} +``` + +The runtime config intentionally accepts only simple local PNG/JPEG/WebP filenames. + +Do not loosen validation to arbitrary URLs or nested paths without a clear requirement and security review. + +Current validation should reject: + +- empty fields; +- duplicate IDs; +- duplicate files; +- remote URLs; +- nested paths; +- path traversal; +- unsupported file extensions. + +Adding a predefined overlay should normally require only: + +1. copying the image into the overlay directory; +2. adding an entry to `overlays.json`. + +Do not require a Vue rebuild just to add a new runtime overlay. + +The Docker/nginx configuration intentionally makes `/overlays/` revalidate rather than cache for a week, because deployed logos may be replaced without rebuilding the application. + +--- + +## UI and accessibility + +The editor must work on: + +- desktop; +- tablet; +- mobile; +- mouse; +- touch; +- keyboard for non-drag alternatives. + +Do not make drag the only way to manipulate an object. + +Maintain: + +- numeric X control; +- numeric Y control; +- numeric width control; +- accessible overlay selection; +- quick corner buttons; +- visible focus states; +- useful labels. + +If multiple overlays exist, keyboard users must have a way to select an existing overlay without clicking the canvas. + +Do not introduce horizontal page scrolling for normal mobile layouts. + +The editor is the primary visual element. Avoid turning the app into a complex dashboard. + +--- + +## Internationalization + +The interface supports: + +- English; +- Portuguese (Portugal). + +User-visible application strings should go through `useLocale.ts`. + +Do not hardcode new English UI text directly in components unless it is truly non-user-visible. + +When adding a string: + +- add the English translation; +- add the PT-PT translation; +- use Portuguese from Portugal, not Brazilian Portuguese; +- preserve proper accents and spelling. + +Examples of preferred PT-PT wording: + +- `ficheiro` +- `imagem` +- `sobreposição` +- `carregar` +- `descarregar` +- `área de edição` + +Avoid Brazilian-specific UI vocabulary where a natural PT-PT alternative exists. + +ARIA labels that are user-facing should also be translated. + +--- + +## Security and public-repository rules + +This repository is public. + +### Secrets + +Never commit: + +- API keys; +- passwords; +- access tokens; +- private keys; +- private certificates; +- deployment credentials; +- private infrastructure details that are intended to remain private. + +Real local environment files must remain ignored. + +The repository should contain: + +```text +.env.example +``` + +with safe example/non-secret values only. + +### Frontend environment variables + +Do not put secrets in Vite/frontend variables. + +Anything bundled into browser JavaScript is public. + +In particular: + +```text +VITE_* +``` + +must never be treated as secret storage. + +The current project intentionally avoids Vite deployment configuration where runtime static configuration is simpler. + +If a future feature requires a real secret, do not "hide" it in the frontend. Propose a server-side boundary or a deployment secret mechanism. + +### External services + +Do not add: + +- telemetry; +- analytics; +- remote fonts; +- remote scripts; +- CDN runtime dependencies; +- third-party upload services; + +unless explicitly requested and reviewed against privacy/CSP requirements. + +The current restrictive nginx Content Security Policy is intentional. + +### Secret scanning + +Preserve and run: + +```sh +npm run security-check +``` + +Do not change secret scanning to print actual discovered secret values into CI logs. Report filenames/types, not secret contents. + +### Dependency security + +Do not weaken the Fabric minimum-version security check. + +Keep nginx on a maintained/patched release. + +When changing dependencies, inspect current advisories and run the dependency audit. + +--- + +## Docker and nginx + +The production image is a static nginx deployment. + +Keep it that way unless requirements change. + +Important behavior: + +- `.env` must not be copied into the image; +- overlay directory is mounted read-only; +- runtime overlays are served from `/overlays/`; +- overlay files should revalidate so replacements appear promptly; +- the app is normally deployed at the root of a hostname; +- security headers and CSP are intentional. + +If changing nginx: + +- preserve SPA/static asset behavior; +- preserve overlay runtime behavior; +- validate the nginx configuration; +- review CSP implications of any new browser capability. + +Do not hardcode private domains, tokens, or infrastructure credentials in Docker/nginx files. + +--- + +## Testing expectations + +Tests should focus on behavior that is easy to regress. + +Pure logic belongs in utilities so it can be tested without a browser where possible. + +At minimum, add/update tests when changing: + +- coordinate scaling; +- bounds; +- resize behavior; +- corner positioning; +- default overlay sizing; +- overlay configuration parsing/validation. + +For editor behavior that depends on Fabric/browser APIs, prefer focused browser/integration tests if a unit test would only mock away the behavior being verified. + +Important scenarios include: + +```text +1280 × 1280 +1920 × 1080 +1080 × 1920 +4032 × 3024 +``` + +Verify that browser/editor resizing does not change logical coordinates. + +When touching export logic, verify: + +- PNG output dimensions; +- JPEG output dimensions; +- object placement; +- multiple overlay stacking; +- absence of Fabric controls; +- editor state restoration after export; +- failure handling where practical. + +--- + +## Code style + +Follow the existing TypeScript/Vue style. + +General expectations: + +- strict TypeScript; +- explicit types at important boundaries; +- small focused functions; +- pure helpers for math/validation; +- avoid `any`; +- avoid non-null assertions unless the invariant is genuinely guaranteed; +- avoid duplicated logic; +- prefer early returns for invalid state; +- keep async error handling explicit; +- clean up browser resources and listeners. + +Do not add abstractions solely to anticipate hypothetical future features. + +Do not commit: + +- `dist/`; +- `node_modules/`; +- local `.env`; +- editor/IDE junk; +- temporary output images; +- debug artifacts. + +Keep LF line endings and no trailing whitespace. + +--- + +## Error handling + +Errors shown to users should be: + +- concise; +- human-readable; +- translated; +- actionable where possible. + +Do not expose stack traces in the normal UI. + +A failure loading one predefined overlay should not make custom uploads unusable. + +Export and decode failures should leave the existing editor state usable. + +Do not swallow unexpected errors silently if they prevent the requested action. + +--- + +## Scope discipline + +The following are currently **non-goals** unless explicitly requested: + +- authentication; +- user accounts; +- database; +- server-side persistence; +- cloud storage; +- project history; +- full layers panel; +- text editor; +- rotation UI; +- filters; +- cropping; +- image effects; +- collaborative editing; +- admin interface; +- remote APIs. + +Potential future features may be mentioned in `PLAN.md`, but their presence there is not permission to implement them during unrelated tasks. + +Do not choose or add a software license on the user's behalf unless explicitly requested. + +--- + +## Documentation rules + +Update documentation when a change affects what users or deployers need to know. + +Update **README.md** for changes to: + +- setup; +- commands; +- supported formats; +- deployment; +- overlay configuration; +- privacy/security behavior; +- browser-visible behavior. + +Update **PLAN.md** for changes to: + +- architecture; +- requirements; +- acceptance criteria; +- project scope; +- implementation direction. + +Keep documentation aligned with actual code. Do not document features that are not implemented. + +--- + +## Definition of done + +A change is normally complete when: + +- the requested behavior is implemented; +- core product invariants remain intact; +- relevant tests are added or updated; +- TypeScript passes; +- tests pass; +- repository quality checks pass; +- security checks pass; +- production build passes; +- dependency audit is reviewed if dependencies changed; +- Docker/nginx is validated if deployment files changed; +- no secrets or generated artifacts were introduced; +- README/PLAN are updated when needed; +- the final diff contains no unrelated refactors. + +The goal is not merely to make the requested change work once. It should remain correct for large images, mobile devices, multiple overlays, public deployment, and pixel-accurate export. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..239bcfc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,16 @@ +FROM node:22-alpine AS build + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY . . +RUN npm run build + +FROM nginx:1.30.4-alpine + +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /app/dist /usr/share/nginx/html + +EXPOSE 80 diff --git a/README.md b/README.md new file mode 100644 index 0000000..d82bbb8 --- /dev/null +++ b/README.md @@ -0,0 +1,146 @@ +# Local Image Editor + +A fast, privacy-friendly browser image overlay editor. Upload a PNG, JPEG, or WebP image, add one or more predefined or custom overlays, position them precisely, and download the result at the source image's original resolution. + +All user image processing happens locally in the browser. The project has no backend, database, image upload endpoint, account system, or credentials. + +The interface supports English and Portuguese (Portugal). It uses the browser language by default and remembers the selected language locally. + +## Highlights + +- Multiple independent overlays +- Runtime-configured overlay library +- Custom local overlay uploads +- Mouse, touch, and numeric X/Y/width controls +- Accessible overlay selector for keyboard users +- Four quick corner positions with bounds enforcement +- Original-image pixels remain the canonical coordinate system +- Low-resolution interactive Fabric.js backing canvas for better mobile memory usage +- Full-resolution PNG/JPEG export only when needed +- JPEG export explicitly flattens transparency onto white +- No server-side image processing + +## Run locally + +Requirements: Node.js 22 or newer and npm. + +```sh +npm ci +npm run dev +``` + +Open the URL printed by Vite. + +Useful checks: + +```sh +npm run type-check +npm test +npm run lint +npm run security-check +npm run build +``` + +Or run all non-build checks together: + +```sh +npm run check +``` + +The application uses Fabric only in the browser. CI and Docker use `npm ci` so installs are reproducible from the committed lockfile. + +## Docker + +Create an optional local `.env` from the public example and set a host port or overlay directory if needed: + +```sh +cp .env.example .env +docker compose up --build +``` + +The application is served at `http://localhost:8080` unless `APP_PORT` is changed. + +Compose mounts `OVERLAYS_DIR` read-only at `/usr/share/nginx/html/overlays` in the container. It defaults to `./public/overlays`: + +```env +OVERLAYS_DIR=/srv/local-image-editor/overlays +``` + +Images and `overlays.json` in that directory are served with revalidation rather than week-long caching, so a replaced logo is picked up on reload without rebuilding the container. + +The included nginx configuration adds a restrictive Content Security Policy and basic browser security headers. + +## Overlay library + +Predefined overlays are loaded at runtime from `public/overlays/overlays.json`. In Docker deployments, this is the directory configured by `OVERLAYS_DIR`. + +Example entry: + +```json +{ + "id": "example-badge", + "name": "Example badge", + "file": "example-badge.png" +} +``` + +Overlay configuration is intentionally restricted to simple local PNG/JPEG/WebP filenames. Empty fields, duplicate IDs/files, nested paths, remote URLs, path traversal, and unsupported extensions are rejected. + +To add an overlay: + +1. Copy the image into the overlay directory. +2. Add its entry to `overlays.json`. +3. Reload the application. + +No Vue source change or image rebuild is required. + +## Coordinate and rendering model + +The source image's original dimensions are always the logical coordinate system. For example, a `4032 x 3024` image remains a `4032 x 3024` logical scene even when the editor is displayed at `900 x 675`. + +Fabric.js uses a viewport transform to render that scene into a smaller interactive backing store. This keeps X/Y/width values pixel-accurate while avoiding multiple full-resolution canvas surfaces during normal editing. + +During export, the canvas is temporarily rendered at the exact source dimensions. The normal low-resolution editor state and selection are restored in a `finally` block even if export fails. + +## Public repository and configuration security + +The repository is designed to be safe to publish. + +- `.env` and `.env.*` are ignored by Git and Docker, except `.env.example`. +- `.env.example` contains only non-sensitive Docker Compose values. +- There are no `VITE_*` deployment settings because Vite values are compiled into browser JavaScript and therefore cannot hold secrets. +- If a future feature requires a real secret, it must be handled by a server-side component or deployment secret store and must never be bundled into this frontend. +- CI runs a small repository security check and a production dependency audit. + +Do not commit credentials, tokens, passwords, private keys, private certificates, or private deployment configuration. + +## Runtime/deployment path + +The Docker/nginx setup expects the application to be exposed at the root of a hostname, for example: + +```text +https://editor.example.com/ +``` + +The Vite bundle itself uses relative asset URLs, but if a reverse proxy publishes it under a path prefix, the proxy must strip that prefix before forwarding requests to the included nginx container so `/overlays/` continues to resolve correctly. + +## Quality checks + +A Gitea Actions workflow is provided in `.gitea/workflows/quality.yml`. It runs: + +```text +npm ci +npm run check +npm run build +npm audit --omit=dev --omit=optional --audit-level=high +``` + +Unit tests cover coordinate/display scaling, transformed overlay bounds, corner positioning, and overlay-library validation. The repository check also detects common formatting/configuration mistakes and basic accidental secret material. + +## Replacing the background + +If overlays already exist, the app asks for confirmation before replacing the background because the current composition will be cleared. The header branding is intentionally not a link so it cannot accidentally reload and discard a composition. + +## Licensing + +This repository may be publicly readable, but public visibility alone does not grant reuse or redistribution rights. Choose and add a `LICENSE` file explicitly if the project is intended to be open source. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..c2916c6 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + image-editor: + build: . + restart: unless-stopped + ports: + - "${APP_PORT:-8080}:80" + volumes: + # Overlay assets and their runtime configuration can change without rebuilding the image. + - "${OVERLAYS_DIR:-./public/overlays}:/usr/share/nginx/html/overlays:ro" diff --git a/index.html b/index.html new file mode 100644 index 0000000..dd5c870 --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + + Local Image Editor + + +
+ + + diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..6f7a17c --- /dev/null +++ b/nginx.conf @@ -0,0 +1,33 @@ +server { + listen 80; + listen [::]:80; + server_name _; + server_tokens off; + + root /usr/share/nginx/html; + index index.html; + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; img-src 'self' blob: data:; style-src 'self' 'unsafe-inline'; script-src 'self'; connect-src 'self'; font-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'; worker-src 'none'" always; + + # Runtime-managed overlays must be revalidated so replacing a logo or + # overlays.json takes effect without rebuilding the application image. + location ^~ /overlays/ { + expires -1; + try_files $uri =404; + } + + # Vite-generated assets are content-hashed and can be cached safely. + location ~* \.(?:css|js|mjs|png|jpe?g|webp|woff2?)$ { + expires 7d; + try_files $uri =404; + } + + # Keep the application shell fresh and support SPA navigation. + location / { + expires -1; + try_files $uri $uri/ /index.html; + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..eabee26 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1544 @@ +{ + "name": "local-image-editor", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "local-image-editor", + "version": "0.1.0", + "dependencies": { + "fabric": "^7.4.0", + "vue": "^3.5.13" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.7.2", + "vite": "^6.0.5", + "vue-tsc": "^2.2.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "license": "MIT" + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", + "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", + "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", + "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", + "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", + "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", + "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", + "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", + "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", + "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", + "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", + "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", + "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", + "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", + "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", + "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", + "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", + "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz", + "integrity": "sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", + "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", + "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", + "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", + "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", + "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", + "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", + "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.15.tgz", + "integrity": "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.15" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.15.tgz", + "integrity": "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.15", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.15.tgz", + "integrity": "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.42.tgz", + "integrity": "sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/shared": "3.5.42", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.42.tgz", + "integrity": "sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.42.tgz", + "integrity": "sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@vue/compiler-core": "3.5.42", + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-ssr": "3.5.42", + "@vue/shared": "3.5.42", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.19", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.42.tgz", + "integrity": "sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/compiler-vue2": { + "version": "2.7.16", + "resolved": "https://registry.npmjs.org/@vue/compiler-vue2/-/compiler-vue2-2.7.16.tgz", + "integrity": "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/@vue/language-core": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-2.2.12.tgz", + "integrity": "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.15", + "@vue/compiler-dom": "^3.5.0", + "@vue/compiler-vue2": "^2.7.16", + "@vue/shared": "^3.5.0", + "alien-signals": "^1.0.3", + "minimatch": "^9.0.3", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vue/language-core/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.42.tgz", + "integrity": "sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.42.tgz", + "integrity": "sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.42.tgz", + "integrity": "sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.42", + "@vue/runtime-core": "3.5.42", + "@vue/shared": "3.5.42", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.42.tgz", + "integrity": "sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/shared": "3.5.42" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.42.tgz", + "integrity": "sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==", + "license": "MIT" + }, + "node_modules/alien-signals": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-1.0.13.tgz", + "integrity": "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fabric": { + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/fabric/-/fabric-7.4.0.tgz", + "integrity": "sha512-NalYDc3eifTl1C33zryQwpH6+XA/2ClxQrH9vkASkZw3tbkRmorpikhYMmxhUTmi7O3e9ODz0vOT8qfaCh9IVA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.63.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.63.1.tgz", + "integrity": "sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.63.1", + "@rollup/rollup-android-arm64": "4.63.1", + "@rollup/rollup-darwin-arm64": "4.63.1", + "@rollup/rollup-darwin-x64": "4.63.1", + "@rollup/rollup-freebsd-arm64": "4.63.1", + "@rollup/rollup-freebsd-x64": "4.63.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", + "@rollup/rollup-linux-arm-musleabihf": "4.63.1", + "@rollup/rollup-linux-arm64-gnu": "4.63.1", + "@rollup/rollup-linux-arm64-musl": "4.63.1", + "@rollup/rollup-linux-loong64-gnu": "4.63.1", + "@rollup/rollup-linux-loong64-musl": "4.63.1", + "@rollup/rollup-linux-ppc64-gnu": "4.63.1", + "@rollup/rollup-linux-ppc64-musl": "4.63.1", + "@rollup/rollup-linux-riscv64-gnu": "4.63.1", + "@rollup/rollup-linux-riscv64-musl": "4.63.1", + "@rollup/rollup-linux-s390x-gnu": "4.63.1", + "@rollup/rollup-linux-x64-gnu": "4.63.1", + "@rollup/rollup-linux-x64-musl": "4.63.1", + "@rollup/rollup-openbsd-x64": "4.63.1", + "@rollup/rollup-openharmony-arm64": "4.63.1", + "@rollup/rollup-win32-arm64-msvc": "4.63.1", + "@rollup/rollup-win32-ia32-msvc": "4.63.1", + "@rollup/rollup-win32-x64-gnu": "4.63.1", + "@rollup/rollup-win32-x64-msvc": "4.63.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.2.0.tgz", + "integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.42", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.42.tgz", + "integrity": "sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.42", + "@vue/compiler-sfc": "3.5.42", + "@vue/runtime-dom": "3.5.42", + "@vue/server-renderer": "3.5.42", + "@vue/shared": "3.5.42" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-tsc": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-2.2.12.tgz", + "integrity": "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.15", + "@vue/language-core": "2.2.12" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1b847e5 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "local-image-editor", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "type-check": "vue-tsc -b", + "test": "node --experimental-strip-types --test tests/*.test.ts", + "lint": "node scripts/quality-check.mjs", + "security-check": "node scripts/security-check.mjs", + "check": "npm run type-check && npm run test && npm run lint && npm run security-check" + }, + "dependencies": { + "fabric": "^7.4.0", + "vue": "^3.5.13" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "@vitejs/plugin-vue": "^5.2.1", + "typescript": "~5.7.2", + "vite": "^6.0.5", + "vue-tsc": "^2.2.0" + } +} diff --git a/public/overlays/crr-logo-extenso.png b/public/overlays/crr-logo-extenso.png new file mode 100644 index 0000000..686f6fa Binary files /dev/null and b/public/overlays/crr-logo-extenso.png differ diff --git a/public/overlays/crr-logo-sem-interior.png b/public/overlays/crr-logo-sem-interior.png new file mode 100644 index 0000000..19ab456 Binary files /dev/null and b/public/overlays/crr-logo-sem-interior.png differ diff --git a/public/overlays/crr-logo-white-backgroud.png b/public/overlays/crr-logo-white-backgroud.png new file mode 100644 index 0000000..3058d52 Binary files /dev/null and b/public/overlays/crr-logo-white-backgroud.png differ diff --git a/public/overlays/overlays.json b/public/overlays/overlays.json new file mode 100644 index 0000000..04384b7 --- /dev/null +++ b/public/overlays/overlays.json @@ -0,0 +1,17 @@ +[ + { + "id": "crr-logo-white-bc", + "name": "Logo CRR com fundo branco", + "file": "crr-logo-white-backgroud.png" + }, + { + "id": "crr-logo-sem-interior", + "name": "Logo CRR Sem Interior", + "file": "crr-logo-sem-interior.png" + }, + { + "id": "crr-logo-extenso", + "name": "Logo CRR Nome Completo", + "file": "crr-logo-extenso.png" + } +] \ No newline at end of file diff --git a/scripts/quality-check.mjs b/scripts/quality-check.mjs new file mode 100644 index 0000000..f44fb79 --- /dev/null +++ b/scripts/quality-check.mjs @@ -0,0 +1,58 @@ +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { extname, join, relative } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const textExtensions = new Set(['.css', '.html', '.js', '.json', '.md', '.mjs', '.ts', '.vue', '.yml', '.yaml']) +const textFileNames = new Set(['Dockerfile', 'nginx.conf', '.dockerignore', '.gitignore']) +const ignoredDirectories = new Set(['.git', 'dist', 'node_modules']) +const problems = [] + +function walk(directory) { + for (const name of readdirSync(directory)) { + if (ignoredDirectories.has(name)) continue + const path = join(directory, name) + const stats = statSync(path) + if (stats.isDirectory()) { + walk(path) + continue + } + if (!textExtensions.has(extname(name)) && !textFileNames.has(name) && !name.startsWith('.env')) continue + + const text = readFileSync(path, 'utf8') + const file = relative(root, path) + if (/\r/.test(text)) problems.push(`${file}: use LF line endings`) + text.split('\n').forEach((line, index) => { + if (/[ \t]+$/.test(line)) problems.push(`${file}:${index + 1}: trailing whitespace`) + }) + } +} + +walk(root) + +const config = JSON.parse(readFileSync(join(root, 'public/overlays/overlays.json'), 'utf8')) +if (!Array.isArray(config)) problems.push('public/overlays/overlays.json: expected an array') +const ids = new Set() +const files = new Set() +for (const [index, item] of (Array.isArray(config) ? config : []).entries()) { + const id = typeof item?.id === 'string' ? item.id.trim() : '' + const name = typeof item?.name === 'string' ? item.name.trim() : '' + const file = typeof item?.file === 'string' ? item.file.trim() : '' + if (!id || !name || !/^[A-Za-z0-9][A-Za-z0-9._ -]*\.(?:png|jpe?g|webp)$/i.test(file) || file.includes('..')) { + problems.push(`public/overlays/overlays.json: invalid entry ${index + 1}`) + } + if (ids.has(id)) problems.push(`public/overlays/overlays.json: duplicate id ${id}`) + if (files.has(file)) problems.push(`public/overlays/overlays.json: duplicate file ${file}`) + if (file && !existsSync(join(root, 'public/overlays', file))) { + problems.push(`public/overlays/overlays.json: missing file ${file}`) + } + ids.add(id) + files.add(file) +} + +if (problems.length) { + console.error(problems.join('\n')) + process.exit(1) +} + +console.log('Quality checks passed.') diff --git a/scripts/security-check.mjs b/scripts/security-check.mjs new file mode 100644 index 0000000..dfcd124 --- /dev/null +++ b/scripts/security-check.mjs @@ -0,0 +1,76 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = fileURLToPath(new URL('../', import.meta.url)) +const problems = [] +const envExample = readFileSync(join(root, '.env.example'), 'utf8') + +for (const line of envExample.split('\n')) { + const match = /^([A-Z0-9_]+)=(.*)$/.exec(line.trim()) + if (!match) continue + const [, key, value] = match + const isSecretKey = /(SECRET|TOKEN|PASSWORD|PRIVATE_KEY|API_KEY)/.test(key) + const isPlaceholder = !value || /^(?:change[-_]?me|example|placeholder|your[-_].*|<.*>)$/i.test(value) + if (isSecretKey && !isPlaceholder) { + problems.push(`.env.example must not contain a real-looking value for ${key}`) + } + if (key.startsWith('VITE_') && /(SECRET|TOKEN|PASSWORD|KEY)/.test(key)) { + problems.push(`${key} would be public because VITE_* values are bundled into browser code`) + } + if (/-----BEGIN .*PRIVATE KEY-----/.test(value)) { + problems.push(`.env.example contains private-key material in ${key}`) + } +} + +const dockerfile = readFileSync(join(root, 'Dockerfile'), 'utf8') +if (/COPY\s+[^\n]*\.env(?:\s|$)/i.test(dockerfile)) { + problems.push('Dockerfile must not copy .env files explicitly') +} + +const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) +const fabricVersion = packageJson.dependencies?.fabric ?? '' +const fabricMatch = /^[~^]?(\d+)\.(\d+)\.(\d+)/.exec(fabricVersion) +const fabricMajor = Number(fabricMatch?.[1] ?? 0) +const fabricMinor = Number(fabricMatch?.[2] ?? 0) +if (!fabricMatch || fabricMajor < 7 || (fabricMajor === 7 && fabricMinor < 4)) { + problems.push(`Fabric must be at least 7.4.0; found ${fabricVersion || 'missing'}`) +} + +if (existsSync(join(root, '.git'))) { + try { + const trackedEnvFiles = execFileSync('git', ['ls-files', '.env', '.env.*'], { + cwd: root, + encoding: 'utf8', + }).trim().split('\n').filter(Boolean).filter((file) => file !== '.env.example') + if (trackedEnvFiles.length) problems.push(`Tracked environment files: ${trackedEnvFiles.join(', ')}`) + + const credentialPattern = '-----BEGIN (RSA |EC |OPENSSH )?PRIVATE KEY-----|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|AIza[0-9A-Za-z_-]{20,}' + const grep = execFileSync( + 'git', + ['grep', '-lEI', '--', credentialPattern], + { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }, + ).trim() + if (grep) problems.push(`Potential credential material found in tracked files: ${grep.split('\n').join(', ')}`) + + const history = execFileSync( + 'git', + ['log', '--all', '-p', '--no-ext-diff', '--no-textconv', '--no-color', '--format='], + { cwd: root, encoding: 'utf8', maxBuffer: 20 * 1024 * 1024 }, + ) + if (new RegExp(credentialPattern, 'i').test(history)) { + problems.push('Potential credential material exists in Git history') + } + } catch (error) { + // git grep returns exit code 1 when there are no matches, which is expected. + if (error?.status !== 1) problems.push('Unable to complete Git secret scan') + } +} + +if (problems.length) { + console.error(problems.join('\n')) + process.exit(1) +} + +console.log('Security checks passed.') diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..24fb2de --- /dev/null +++ b/src/App.vue @@ -0,0 +1,135 @@ + + + diff --git a/src/components/BackgroundUpload.vue b/src/components/BackgroundUpload.vue new file mode 100644 index 0000000..21ddecc --- /dev/null +++ b/src/components/BackgroundUpload.vue @@ -0,0 +1,59 @@ + + + diff --git a/src/components/ExportControls.vue b/src/components/ExportControls.vue new file mode 100644 index 0000000..fd1cddd --- /dev/null +++ b/src/components/ExportControls.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/components/ImageEditor.vue b/src/components/ImageEditor.vue new file mode 100644 index 0000000..8199b40 --- /dev/null +++ b/src/components/ImageEditor.vue @@ -0,0 +1,73 @@ + + + diff --git a/src/components/OverlayControls.vue b/src/components/OverlayControls.vue new file mode 100644 index 0000000..c89f0a9 --- /dev/null +++ b/src/components/OverlayControls.vue @@ -0,0 +1,82 @@ + + + diff --git a/src/components/OverlayPicker.vue b/src/components/OverlayPicker.vue new file mode 100644 index 0000000..faf76c4 --- /dev/null +++ b/src/components/OverlayPicker.vue @@ -0,0 +1,82 @@ + + + diff --git a/src/composables/useFabricEditor.ts b/src/composables/useFabricEditor.ts new file mode 100644 index 0000000..4729acf --- /dev/null +++ b/src/composables/useFabricEditor.ts @@ -0,0 +1,472 @@ +import { Canvas, FabricImage, type FabricObject } from 'fabric' +import { nextTick, onBeforeUnmount, ref, shallowRef } from 'vue' +import { useLocale } from '@/composables/useLocale' +import { + clampOverlayTransform, + DEFAULT_OVERLAY_WIDTH_RATIO, + MIN_OVERLAY_WIDTH, + positionInCorner, +} from '@/utils/editorMath' +import { isSupportedImageFile } from '@/utils/imageFiles' +import type { + EditorHandle, + ExportFormat, + ImageDimensions, + OverlayCorner, + OverlaySummary, + SelectedOverlay, +} from '@/types/editor' + +const IDENTITY_VIEWPORT: [number, number, number, number, number, number] = [1, 0, 0, 1, 0, 0] + +type OverlayFabricObject = FabricImage & { + overlayId: string + overlayName: string +} + +interface UseFabricEditorOptions { + canvasElement: () => HTMLCanvasElement | null + onBackgroundChange: (dimensions: ImageDimensions | null) => void + onSelectionChange: (overlay: SelectedOverlay | null) => void + onOverlaysChange: (overlays: OverlaySummary[]) => void + onError: (message: string) => void +} + +function rounded(value: number): number { + return Math.round(Number.isFinite(value) ? value : 0) +} + +function isOverlay(object: FabricObject | null | undefined): object is OverlayFabricObject { + return Boolean(object && 'overlayId' in object && 'overlayName' in object) +} + +export function useFabricEditor(options: UseFabricEditorOptions): EditorHandle { + const { t } = useLocale() + const canvas = shallowRef(null) + const backgroundDimensions = ref(null) + let selectedOverlay: OverlayFabricObject | null = null + let backgroundGeneration = 0 + let exportInProgress = false + + function reportError(message: string): void { + options.onError(message) + } + + function getCanvas(): Canvas | null { + if (!canvas.value) { + reportError(t('editorLoading')) + } + + return canvas.value + } + + function selectedDetails(object: OverlayFabricObject): SelectedOverlay { + return { + id: object.overlayId, + name: object.overlayName, + x: rounded(object.left ?? 0), + y: rounded(object.top ?? 0), + width: rounded(object.getScaledWidth()), + height: rounded(object.getScaledHeight()), + } + } + + function publishSelection(object: FabricObject | undefined | null): void { + if (!isOverlay(object)) { + selectedOverlay = null + options.onSelectionChange(null) + return + } + + selectedOverlay = object + options.onSelectionChange(selectedDetails(object)) + } + + function publishOverlays(): void { + const overlays = canvas.value + ?.getObjects() + .filter(isOverlay) + .map((object) => ({ id: object.overlayId, name: object.overlayName })) ?? [] + options.onOverlaysChange(overlays) + } + + function clampOverlay(object: OverlayFabricObject): void { + const dimensions = backgroundDimensions.value + if (!dimensions || !object.width || !object.height) return + + const transform = clampOverlayTransform( + object.width, + object.height, + object.left ?? 0, + object.top ?? 0, + Math.max(object.scaleX ?? 1, object.scaleY ?? 1), + dimensions, + ) + + object.set({ + left: transform.x, + top: transform.y, + scaleX: transform.scale, + scaleY: transform.scale, + }) + object.setCoords() + } + + function updateAfterTransform(target: FabricObject): void { + if (!isOverlay(target)) return + + clampOverlay(target) + publishSelection(target) + canvas.value?.requestRenderAll() + } + + function initializeCanvas(): void { + const element = options.canvasElement() + if (!element || canvas.value) return + + const fabricCanvas = new Canvas(element, { + width: 1, + height: 1, + enableRetinaScaling: false, + preserveObjectStacking: true, + selection: false, + uniformScaling: true, + // Do not let a modifier key switch corner scaling to non-uniform mode. + uniScaleKey: null, + }) + + fabricCanvas.on('selection:created', ({ selected }) => publishSelection(selected[0])) + fabricCanvas.on('selection:updated', ({ selected }) => publishSelection(selected[0])) + fabricCanvas.on('selection:cleared', () => publishSelection(null)) + fabricCanvas.on('object:moving', ({ target }) => { + if (target) updateAfterTransform(target) + }) + fabricCanvas.on('object:scaling', ({ target }) => { + if (target) updateAfterTransform(target) + }) + fabricCanvas.on('object:modified', ({ target }) => { + if (target) updateAfterTransform(target) + }) + + canvas.value = fabricCanvas + } + + function resizeDisplay(width: number, height: number): void { + const fabricCanvas = canvas.value + const dimensions = backgroundDimensions.value + if (!fabricCanvas || !dimensions || width <= 0 || height <= 0) return + + const scale = Math.min(width / dimensions.width, height / dimensions.height, 1) + const renderedWidth = Math.max(1, Math.round(dimensions.width * scale)) + const renderedHeight = Math.max(1, Math.round(dimensions.height * scale)) + + // Keep Fabric object coordinates in original-image pixels while using a much + // smaller interactive backing store. This avoids allocating multiple full- + // resolution canvas surfaces during normal editing on phones and tablets. + fabricCanvas.setDimensions({ width: renderedWidth, height: renderedHeight }) + fabricCanvas.setViewportTransform([scale, 0, 0, scale, 0, 0]) + fabricCanvas.calcOffset() + fabricCanvas.requestRenderAll() + } + + async function loadImage(source: string, failureMessage: string): Promise { + try { + return await FabricImage.fromURL(source) + } catch { + throw new Error(failureMessage) + } + } + + async function loadBackground(file: File): Promise { + if (!isSupportedImageFile(file)) { + reportError(t('unsupportedBackground')) + return + } + + const fabricCanvas = getCanvas() + if (!fabricCanvas) return + + const generation = ++backgroundGeneration + const source = URL.createObjectURL(file) + try { + const image = await loadImage(source, t('decodeImage')) + if (generation !== backgroundGeneration) return + + const width = image.width ?? 0 + const height = image.height ?? 0 + if (!width || !height) { + throw new Error(t('invalidBackgroundDimensions')) + } + + fabricCanvas.discardActiveObject() + fabricCanvas.clear() + fabricCanvas.setViewportTransform(IDENTITY_VIEWPORT) + fabricCanvas.backgroundImage = image + image.set({ + left: 0, + top: 0, + originX: 'left', + originY: 'top', + scaleX: 1, + scaleY: 1, + selectable: false, + evented: false, + }) + image.setCoords() + + backgroundDimensions.value = { width, height } + selectedOverlay = null + options.onBackgroundChange(backgroundDimensions.value) + options.onSelectionChange(null) + publishOverlays() + } catch (error) { + if (generation === backgroundGeneration) { + reportError(error instanceof Error ? error.message : t('backgroundLoadFailed')) + } + } finally { + URL.revokeObjectURL(source) + } + } + + async function addOverlay(source: string, name: string): Promise { + const fabricCanvas = getCanvas() + const dimensions = backgroundDimensions.value + if (!fabricCanvas || !dimensions) { + reportError(t('uploadBackgroundFirst')) + return + } + + const generation = backgroundGeneration + try { + const failureMessage = source.startsWith('blob:') + ? t('overlayLoadFailed') + : t('predefinedOverlayFailed') + const overlay = (await loadImage(source, failureMessage)) as OverlayFabricObject + + // A background change invalidates overlays that were still decoding. + if (generation !== backgroundGeneration || !backgroundDimensions.value) return + if (!overlay.width || !overlay.height) { + throw new Error(t('invalidOverlayDimensions')) + } + + const currentDimensions = backgroundDimensions.value + const targetWidth = currentDimensions.width * DEFAULT_OVERLAY_WIDTH_RATIO + const scale = Math.min(targetWidth / overlay.width, currentDimensions.height / overlay.height) + const id = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random()}` + Object.assign(overlay, { overlayId: id, overlayName: name }) + overlay.set({ + left: 0, + top: 0, + originX: 'left', + originY: 'top', + scaleX: scale, + scaleY: scale, + selectable: true, + evented: true, + lockRotation: true, + lockScalingFlip: true, + lockSkewingX: true, + lockSkewingY: true, + }) + overlay.setControlsVisibility({ + mtr: false, + ml: false, + mr: false, + mt: false, + mb: false, + }) + clampOverlay(overlay) + const corner = positionInCorner( + 'top-right', + { width: overlay.getScaledWidth(), height: overlay.getScaledHeight() }, + currentDimensions, + ) + overlay.set({ left: corner.x, top: corner.y }) + overlay.setCoords() + + fabricCanvas.add(overlay) + fabricCanvas.setActiveObject(overlay) + publishOverlays() + updateAfterTransform(overlay) + } catch (error) { + if (generation === backgroundGeneration) { + reportError(error instanceof Error ? error.message : t('overlayLoadFailed')) + } + } + } + + async function addCustomOverlay(file: File): Promise { + if (!isSupportedImageFile(file)) { + reportError(t('unsupportedOverlay')) + return + } + + const source = URL.createObjectURL(file) + try { + await addOverlay(source, file.name) + } finally { + URL.revokeObjectURL(source) + } + } + + function selectOverlay(id: string): void { + const fabricCanvas = canvas.value + if (!fabricCanvas) return + + const object = fabricCanvas.getObjects().find( + (candidate): candidate is OverlayFabricObject => isOverlay(candidate) && candidate.overlayId === id, + ) + if (!object) return + + fabricCanvas.setActiveObject(object) + publishSelection(object) + fabricCanvas.requestRenderAll() + } + + function updateSelected(changes: Partial>): void { + if (!selectedOverlay || !backgroundDimensions.value) return + + const object = selectedOverlay + const updates: Partial = {} + if (typeof changes.x === 'number' && Number.isFinite(changes.x)) { + updates.left = rounded(changes.x) + } + if (typeof changes.y === 'number' && Number.isFinite(changes.y)) { + updates.top = rounded(changes.y) + } + if (typeof changes.width === 'number' && Number.isFinite(changes.width) && object.width) { + const width = Math.max(MIN_OVERLAY_WIDTH, rounded(changes.width)) + const scale = width / object.width + updates.scaleX = scale + updates.scaleY = scale + } + + object.set(updates) + updateAfterTransform(object) + } + + function positionSelected(corner: OverlayCorner): void { + const dimensions = backgroundDimensions.value + if (!selectedOverlay || !dimensions) return + + const position = positionInCorner( + corner, + { width: selectedOverlay.getScaledWidth(), height: selectedOverlay.getScaledHeight() }, + dimensions, + ) + selectedOverlay.set({ left: position.x, top: position.y }) + updateAfterTransform(selectedOverlay) + } + + function removeSelected(): void { + if (!selectedOverlay || !canvas.value) return + + canvas.value.remove(selectedOverlay) + selectedOverlay = null + options.onSelectionChange(null) + publishOverlays() + canvas.value.requestRenderAll() + } + + async function exportImage(format: ExportFormat): Promise { + const fabricCanvas = getCanvas() + const dimensions = backgroundDimensions.value + if (!fabricCanvas || !dimensions) { + reportError(t('uploadBackgroundBeforeExport')) + return + } + if (exportInProgress) return + + exportInProgress = true + const interactiveWidth = fabricCanvas.width + const interactiveHeight = fabricCanvas.height + const interactiveViewport = [...fabricCanvas.viewportTransform] as [ + number, + number, + number, + number, + number, + number, + ] + const backgroundColor = fabricCanvas.backgroundColor + let blobPromise: Promise | null = null + + try { + // Fabric's toBlob() renders into a separate canvas. Temporarily expose the + // source-image logical dimensions and an identity viewport so that the + // temporary export canvas is exact-size, without resizing the interactive + // DOM/backing canvas or creating a Base64 data URL. + fabricCanvas.width = dimensions.width + fabricCanvas.height = dimensions.height + fabricCanvas.viewportTransform = [...IDENTITY_VIEWPORT] + if (format === 'jpeg') { + fabricCanvas.backgroundColor = '#ffffff' + } + fabricCanvas.calcViewportBoundaries() + + // toBlob() creates/paints its export canvas synchronously before the Blob + // encoding promise settles, so restore editor state immediately below. + blobPromise = fabricCanvas.toBlob({ + format, + quality: format === 'jpeg' ? 0.95 : 1, + multiplier: 1, + }) + } catch (error) { + reportError(error instanceof Error ? error.message : t('exportFailed')) + } finally { + fabricCanvas.backgroundColor = backgroundColor + fabricCanvas.width = interactiveWidth + fabricCanvas.height = interactiveHeight + fabricCanvas.viewportTransform = interactiveViewport + fabricCanvas.calcViewportBoundaries() + fabricCanvas.requestRenderAll() + } + + if (!blobPromise) { + exportInProgress = false + return + } + + let blob: Blob | null = null + try { + blob = await blobPromise + if (!blob) throw new Error(t('exportFailed')) + } catch (error) { + reportError(error instanceof Error ? error.message : t('exportFailed')) + } finally { + exportInProgress = false + } + + if (!blob) return + + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `${t('downloadFilename')}-${new Date().toISOString().slice(0, 10)}.${format === 'jpeg' ? 'jpg' : 'png'}` + document.body.append(link) + link.click() + link.remove() + window.setTimeout(() => URL.revokeObjectURL(url), 10_000) + } + + void nextTick(initializeCanvas) + + onBeforeUnmount(() => { + backgroundGeneration += 1 + const fabricCanvas = canvas.value + canvas.value = null + if (fabricCanvas) void fabricCanvas.dispose() + }) + + return { + resizeDisplay, + loadBackground, + addOverlay, + addCustomOverlay, + selectOverlay, + updateSelected, + positionSelected, + removeSelected, + exportImage, + } +} diff --git a/src/composables/useLocale.ts b/src/composables/useLocale.ts new file mode 100644 index 0000000..500839e --- /dev/null +++ b/src/composables/useLocale.ts @@ -0,0 +1,171 @@ +import { ref, watch } from 'vue' + +export type Locale = 'en' | 'pt' + +export type TranslationKey = keyof typeof translations.en +export type Translate = (key: TranslationKey, values?: Record) => string + +const translations = { + en: { + appName: 'Local Image Editor', + privacyNotice: 'Images are processed locally in your browser and are not uploaded to the server.', + languageLabel: 'Language', + english: 'English', + portuguese: 'Portuguese', + dismissError: 'Dismiss error', + imageDimensions: 'Image: {width} x {height} px', + baseLayer: 'Base layer', + chooseImage: 'Choose your image', + uploadImage: 'Upload an image', + dropImage: 'or drop it here', + supportedFormats: 'PNG, JPEG, or WebP', + topLayer: 'Top layer', + addOverlay: 'Add an overlay', + overlayLibrary: 'Overlay library', + noOverlays: 'No overlays available', + addSelectedOverlay: 'Add selected overlay', + or: 'or', + uploadCustomImage: 'Upload custom image', + fineTune: 'Fine tune', + selectedElement: 'Selected element', + activeElement: 'Active element', + chooseElement: 'Choose an element', + xPosition: 'X position', + yPosition: 'Y position', + width: 'Width', + height: 'Height', + calculatedOverlayHeight: 'Calculated overlay height', + quickPosition: 'Quick position', + topLeft: 'Position top left', + topRight: 'Position top right', + bottomLeft: 'Position bottom left', + bottomRight: 'Position bottom right', + removeElement: 'Remove element', + selectOverlay: 'Select an overlay on the canvas or from the element list to adjust its position and size.', + imageEditor: 'Image editor', + canvasLabel: 'Canvas for positioning image overlays', + imageAppearsHere: 'Your image will appear here.', + uploadToBegin: 'Upload a PNG, JPEG, or WebP image to begin.', + readyWhenYouAre: 'Ready when you are', + downloadImage: 'Download your image', + originalDimensions: "Exports retain your source image's original dimensions.", + exporting: 'Preparing the full-resolution image…', + downloadPng: 'Download PNG', + downloadJpeg: 'Download JPEG', + editorLoading: 'The editor is still loading. Please try again.', + decodeImage: 'The image could not be decoded. Choose a valid PNG, JPEG, or WebP image.', + predefinedOverlayFailed: 'This predefined overlay could not be loaded. Check that its filename in overlays.json exactly matches the file in the overlays directory.', + unsupportedBackground: 'Choose a PNG, JPEG, or WebP image for the background.', + invalidBackgroundDimensions: 'The background image has invalid dimensions.', + backgroundLoadFailed: 'The background could not be loaded.', + replaceBackgroundConfirm: 'Replacing the background will remove all current overlays. Continue?', + uploadBackgroundFirst: 'Upload a background image before adding an overlay.', + invalidOverlayDimensions: 'The overlay image has invalid dimensions.', + overlayLoadFailed: 'The overlay could not be loaded.', + unsupportedOverlay: 'Choose a PNG, JPEG, or WebP image for the overlay.', + uploadBackgroundBeforeExport: 'Upload a background image before exporting.', + exportFailed: 'The full-resolution image could not be exported. Try a smaller source image or another browser.', + overlayConfigFailed: 'The predefined overlay library could not be loaded. You can still upload a custom overlay.', + downloadFilename: 'local-image', + }, + pt: { + appName: 'Editor de Imagens Local', + privacyNotice: 'As imagens são processadas localmente no seu navegador e não são enviadas para o servidor.', + languageLabel: 'Idioma', + english: 'Inglês', + portuguese: 'Português', + dismissError: 'Fechar erro', + imageDimensions: 'Imagem: {width} x {height} px', + baseLayer: 'Camada de base', + chooseImage: 'Escolha a sua imagem', + uploadImage: 'Carregar imagem', + dropImage: 'ou largue-a aqui', + supportedFormats: 'PNG, JPEG ou WebP', + topLayer: 'Camada superior', + addOverlay: 'Adicionar sobreposição', + overlayLibrary: 'Biblioteca de sobreposições', + noOverlays: 'Não há sobreposições disponíveis', + addSelectedOverlay: 'Adicionar sobreposição selecionada', + or: 'ou', + uploadCustomImage: 'Carregar imagem personalizada', + fineTune: 'Ajuste preciso', + selectedElement: 'Elemento selecionado', + activeElement: 'Elemento ativo', + chooseElement: 'Escolher um elemento', + xPosition: 'Posição X', + yPosition: 'Posição Y', + width: 'Largura', + height: 'Altura', + calculatedOverlayHeight: 'Altura calculada da sobreposição', + quickPosition: 'Posição rápida', + topLeft: 'Posicionar no canto superior esquerdo', + topRight: 'Posicionar no canto superior direito', + bottomLeft: 'Posicionar no canto inferior esquerdo', + bottomRight: 'Posicionar no canto inferior direito', + removeElement: 'Remover elemento', + selectOverlay: 'Selecione uma sobreposição na área de edição ou na lista de elementos para ajustar a posição e o tamanho.', + imageEditor: 'Editor de imagens', + canvasLabel: 'Área para posicionar sobreposições de imagem', + imageAppearsHere: 'A sua imagem aparecerá aqui.', + uploadToBegin: 'Carregue uma imagem PNG, JPEG ou WebP para começar.', + readyWhenYouAre: 'Quando estiver pronto', + downloadImage: 'Descarregue a sua imagem', + originalDimensions: 'As exportações mantêm as dimensões originais da imagem.', + exporting: 'A preparar a imagem na resolução original…', + downloadPng: 'Descarregar PNG', + downloadJpeg: 'Descarregar JPEG', + editorLoading: 'O editor ainda está a carregar. Tente novamente.', + decodeImage: 'Não foi possível descodificar a imagem. Escolha uma imagem PNG, JPEG ou WebP válida.', + predefinedOverlayFailed: 'Não foi possível carregar esta sobreposição predefinida. Confirme que o nome em overlays.json corresponde exatamente ao ficheiro na pasta de sobreposições.', + unsupportedBackground: 'Escolha uma imagem PNG, JPEG ou WebP para a imagem de fundo.', + invalidBackgroundDimensions: 'A imagem de fundo tem dimensões inválidas.', + backgroundLoadFailed: 'Não foi possível carregar a imagem de fundo.', + replaceBackgroundConfirm: 'Substituir a imagem de fundo irá remover todas as sobreposições atuais. Continuar?', + uploadBackgroundFirst: 'Carregue uma imagem de fundo antes de adicionar uma sobreposição.', + invalidOverlayDimensions: 'A sobreposição tem dimensões inválidas.', + overlayLoadFailed: 'Não foi possível carregar a sobreposição.', + unsupportedOverlay: 'Escolha uma imagem PNG, JPEG ou WebP para a sobreposição.', + uploadBackgroundBeforeExport: 'Carregue uma imagem de fundo antes de exportar.', + exportFailed: 'Não foi possível exportar a imagem na resolução original. Tente uma imagem mais pequena ou outro navegador.', + overlayConfigFailed: 'Não foi possível carregar a biblioteca de sobreposições predefinidas. Ainda pode carregar uma imagem personalizada.', + downloadFilename: 'imagem-local', + }, +} as const + +function initialLocale(): Locale { + try { + const savedLocale = localStorage.getItem('local-image-editor-locale') + if (savedLocale === 'en' || savedLocale === 'pt') return savedLocale + } catch { + // Storage can be unavailable in hardened/private browser contexts. + } + return navigator.language.toLowerCase().startsWith('pt') ? 'pt' : 'en' +} + +const locale = ref(initialLocale()) + +const t: Translate = (key, values = {}) => { + let message: string = translations[locale.value][key] + for (const [name, value] of Object.entries(values)) { + message = message.replace(`{${name}}`, String(value)) + } + return message +} + +function setLocale(nextLocale: Locale): void { + locale.value = nextLocale +} + +watch(locale, (nextLocale) => { + try { + localStorage.setItem('local-image-editor-locale', nextLocale) + } catch { + // Language selection still works for this session if storage is blocked. + } + document.documentElement.lang = nextLocale === 'pt' ? 'pt-PT' : 'en' + document.title = translations[nextLocale].appName +}, { immediate: true }) + +export function useLocale() { + return { locale, setLocale, t } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..97128ba --- /dev/null +++ b/src/main.ts @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import App from './App.vue' +import './styles/main.css' + +createApp(App).mount('#app') diff --git a/src/styles/main.css b/src/styles/main.css new file mode 100644 index 0000000..98eaa25 --- /dev/null +++ b/src/styles/main.css @@ -0,0 +1,620 @@ +:root { + color: #15373a; + background: #f4f2eb; + font-family: 'Avenir Next', 'Helvetica Neue', Arial, sans-serif; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +* { + box-sizing: border-box; +} + +body { + min-width: 320px; + min-height: 100vh; + margin: 0; +} + +button, +input, +select { + font: inherit; +} + +button, +label, +select { + -webkit-tap-highlight-color: transparent; +} + +button { + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.44; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible, +.dropzone:focus-within { + outline: 3px solid #ee815e; + outline-offset: 3px; +} + +.app-shell { + width: min(1480px, calc(100% - 48px)); + margin: 0 auto; + padding: 26px 0 40px; +} + +.site-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 48px; +} + +.header-tools { + display: flex; + align-items: center; + gap: 17px; +} + +.language-picker select { + width: auto; + height: 31px; + padding: 0 7px; + border-color: #c7cec6; + background: transparent; + color: #53645d; + font-family: 'DM Mono', monospace; + font-size: 9px; + text-transform: uppercase; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + clip-path: inset(50%); +} + +.brand { + margin: 0; + color: #15373a; + font-size: 15px; + font-weight: 800; + letter-spacing: -0.04em; + line-height: 1; +} + +.brand-content { + display: inline-flex; + align-items: center; + gap: 11px; + color: inherit; + text-decoration: none; +} + +.brand-mark { + display: flex; + align-items: flex-end; + gap: 3px; + width: 30px; + height: 30px; + padding: 7px 6px; + background: #ee815e; +} + +.brand-mark i { + display: block; + width: 4px; + background: #15373a; +} + +.brand-mark i:first-child { height: 8px; } +.brand-mark i:nth-child(2) { height: 15px; } +.brand-mark i:last-child { height: 11px; } + +.privacy-notice { + margin: 0; + max-width: 450px; + color: #6c7771; + font-family: 'DM Mono', monospace; + font-size: 10px; + line-height: 1.45; + text-align: right; +} + +.eyebrow { + margin: 0 0 9px; + color: #d56043; + font-family: 'DM Mono', monospace; + font-size: 10px; + font-weight: 500; + letter-spacing: 0.11em; + line-height: 1.2; + text-transform: uppercase; +} + +h2, +p { + margin-top: 0; +} + +.error-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + max-width: 900px; + margin: 0 0 28px; + padding: 14px 16px; + border: 1px solid #e29982; + background: #fff0e9; + color: #90381e; + font-size: 13px; + line-height: 1.45; +} + +.error-notice button { + border: 0; + background: transparent; + color: inherit; + font-family: 'DM Mono', monospace; + font-size: 17px; +} + +.workspace { + display: grid; + grid-template-columns: minmax(280px, 342px) minmax(0, 1fr); + align-items: start; + gap: clamp(25px, 4vw, 68px); +} + +.control-column { + display: grid; + gap: 16px; +} + +.panel { + padding: 23px; + border: 1px solid #d6d6cc; + background: #fbfaf6; +} + +.panel-heading { + display: flex; + align-items: flex-start; + gap: 13px; + margin-bottom: 21px; +} + +.panel-heading .eyebrow { + margin-bottom: 4px; +} + +.step-number { + display: grid; + flex: 0 0 26px; + width: 26px; + height: 26px; + place-items: center; + border-radius: 50%; + background: #15373a; + color: #f7f5ee; + font-family: 'DM Mono', monospace; + font-size: 10px; +} + +h2 { + margin-bottom: 0; + color: #15373a; + font-size: 17px; + font-weight: 800; + letter-spacing: -0.045em; + line-height: 1.15; +} + +.dropzone { + display: flex; + min-height: 148px; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 7px; + border: 1px dashed #9fa9a2; + background: #f4f2eb; + color: #52635d; + cursor: pointer; + font-size: 12px; + text-align: center; + transition: background 150ms ease, border-color 150ms ease; +} + +.dropzone:hover, +.dropzone--active { + border-color: #ee815e; + background: #fff2e8; +} + +.dropzone input, +.upload-button input { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + clip-path: inset(50%); +} + +.dropzone strong { + color: #15373a; +} + +.dropzone small { + color: #8c9690; + font-family: 'DM Mono', monospace; + font-size: 9px; +} + +.dropzone-icon { + display: grid; + width: 28px; + height: 28px; + place-items: center; + border-radius: 50%; + background: #dce6dd; + color: #15373a; + font-size: 17px; + font-weight: 600; +} + +.image-dimensions { + margin: -5px 0 1px; + color: #718079; + font-family: 'DM Mono', monospace; + font-size: 10px; +} + +.field-group { + display: grid; + gap: 8px; +} + +.field-group > label, +.coordinate-grid label, +.quick-position > span, +.calculated-size > span { + color: #64736d; + font-family: 'DM Mono', monospace; + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +select, +.number-input { + min-width: 0; + height: 42px; + border: 1px solid #cfd2c9; + border-radius: 0; + background: #fffefb; + color: #15373a; +} + +select { + width: 100%; + padding: 0 12px; + appearance: auto; + font-size: 13px; +} + +.button { + display: inline-flex; + min-height: 42px; + align-items: center; + justify-content: center; + border: 1px solid transparent; + border-radius: 0; + padding: 10px 14px; + font-size: 12px; + font-weight: 700; + letter-spacing: -0.01em; + line-height: 1.2; + text-decoration: none; + transition: background 150ms ease, border-color 150ms ease, color 150ms ease; +} + +.button--secondary { + margin-top: 4px; + background: #dce6dd; + color: #15373a; +} + +.button--secondary:hover:not(:disabled) { background: #c9daca; } + +.panel-divider { + display: flex; + align-items: center; + gap: 10px; + margin: 18px 0; + color: #9aa39d; + font-family: 'DM Mono', monospace; + font-size: 9px; + text-transform: uppercase; +} + +.panel-divider::before, +.panel-divider::after { + height: 1px; + flex: 1; + background: #deded5; + content: ''; +} + +.button--outline { + width: 100%; + border-color: #15373a; + background: transparent; + color: #15373a; +} + +.button--outline:hover { background: #15373a; color: #fff; } + +.upload-button { cursor: pointer; } + +.controls-panel { min-height: 230px; } + +.active-overlay-picker { + display: grid; + gap: 6px; + margin: -4px 0 18px; + color: #64736d; + font-family: 'DM Mono', monospace; + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.active-overlay-picker select { + font-family: 'Avenir Next', 'Helvetica Neue', Arial, sans-serif; + letter-spacing: normal; + text-transform: none; +} + +.coordinate-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px 11px; +} + +.coordinate-grid label { + display: grid; + gap: 6px; +} + +.number-input { + display: flex; + align-items: center; + overflow: hidden; +} + +.number-input input { + min-width: 0; + width: 100%; + height: 100%; + border: 0; + background: transparent; + padding: 0 6px 0 10px; + color: #15373a; + font-family: 'DM Mono', monospace; + font-size: 12px; +} + +.number-input input:focus-visible { outline: 0; } + +.number-input em { + padding-right: 9px; + color: #97a099; + font-family: 'DM Mono', monospace; + font-size: 9px; + font-style: normal; +} + +.calculated-size { + display: grid; + gap: 6px; +} + +.calculated-size strong { + display: flex; + height: 42px; + align-items: center; + padding: 0 10px; + border: 1px solid #e0e1d9; + color: #60706b; + font-family: 'DM Mono', monospace; + font-size: 12px; + font-weight: 400; +} + +.quick-position { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + gap: 10px; + margin: 20px 0; +} + +.corner-buttons { + display: flex; + gap: 4px; +} + +.corner-buttons button { + display: grid; + width: 31px; + height: 31px; + place-items: center; + border: 1px solid #cfd2c9; + background: #fffefb; + color: #15373a; + font-family: 'DM Mono', monospace; + font-size: 9px; + line-height: 1; +} + +.corner-buttons button:hover { background: #dce6dd; } + +.button--danger { + width: 100%; + border-color: #d56143; + background: transparent; + color: #b94b31; +} + +.button--danger:hover { background: #fff0ea; } + +.control-empty { + margin: 0; + color: #829089; + font-size: 12px; + line-height: 1.65; +} + +.editor-region { + min-width: 0; +} + +.canvas-shell { + display: grid; + min-height: 430px; + place-items: center; + overflow: hidden; + border: 1px solid #d6d6cc; + background-color: #d8ddd5; + background-image: linear-gradient(45deg, #cfd5cc 25%, transparent 25%), linear-gradient(-45deg, #cfd5cc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #cfd5cc 75%), linear-gradient(-45deg, transparent 75%, #cfd5cc 75%); + background-position: 0 0, 0 10px, 10px -10px, -10px 0; + background-size: 20px 20px; +} + +.editor-empty { + display: grid; + place-items: center; + color: #6d7972; + text-align: center; +} + +.editor-empty p { + margin: 12px 0 5px; + font-size: 14px; + font-weight: 700; +} + +.editor-empty small { + font-family: 'DM Mono', monospace; + font-size: 10px; +} + +.empty-mark { + display: grid; + width: 44px; + height: 44px; + place-items: center; + border: 1px solid #91a098; + border-radius: 50%; + color: #60716a; + font-size: 26px; + font-weight: 300; +} + +.editor-canvas { + display: block; + max-width: 100%; + max-height: 100%; +} + +.canvas-shell--empty .canvas-container { + display: none; +} + +.export-panel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 28px; + margin-top: clamp(28px, 4vw, 55px); + padding: 26px 29px; + background: #e5b35e; +} + +.export-panel .eyebrow { color: #79551b; } +.export-panel h2 { color: #15373a; } + +.export-panel p:last-child { + max-width: 420px; + margin: 8px 0 0; + color: #5e532e; + font-size: 12px; + line-height: 1.5; +} + +.export-actions { + display: flex; + flex: 0 0 auto; + gap: 9px; +} + +.button--primary { background: #15373a; color: #fff; } +.button--primary:hover:not(:disabled) { background: #0a2528; } +.button--dark { border-color: #15373a; background: transparent; color: #15373a; } +.button--dark:hover:not(:disabled) { background: #f5dc9d; } + +@media (max-width: 860px) { + .workspace { + grid-template-columns: 1fr; + } + + .control-column { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .controls-panel, + .image-dimensions { + grid-column: 1 / -1; + } + + .editor-region { grid-row: 1; } +} + +@media (max-width: 590px) { + .app-shell { + width: min(100% - 30px, 1480px); + padding-top: 18px; + } + + .site-header { margin-bottom: 30px; } + + .site-header { align-items: flex-start; } + .header-tools { align-items: flex-end; flex-direction: column-reverse; gap: 6px; } + .privacy-notice { max-width: 185px; font-size: 8px; } + .intro { padding: 58px 0 38px; } + .intro-copy { font-size: 14px; } + .control-column { grid-template-columns: 1fr; } + .controls-panel, + .image-dimensions { grid-column: auto; } + .canvas-shell { min-height: 280px; } + .export-panel { align-items: flex-start; flex-direction: column; padding: 23px; } + .export-actions { width: 100%; } + .export-actions .button { flex: 1; padding-right: 8px; padding-left: 8px; } +} diff --git a/src/types/editor.ts b/src/types/editor.ts new file mode 100644 index 0000000..8405c82 --- /dev/null +++ b/src/types/editor.ts @@ -0,0 +1,37 @@ +export interface ImageDimensions { + width: number + height: number +} + +export interface OverlayLibraryItem { + id: string + name: string + file: string +} + +export interface OverlaySummary { + id: string + name: string +} + +export interface SelectedOverlay extends OverlaySummary { + x: number + y: number + width: number + height: number +} + +export type OverlayCorner = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' +export type ExportFormat = 'png' | 'jpeg' + +export interface EditorHandle { + resizeDisplay: (width: number, height: number) => void + loadBackground: (file: File) => Promise + addOverlay: (source: string, name: string) => Promise + addCustomOverlay: (file: File) => Promise + selectOverlay: (id: string) => void + updateSelected: (changes: Partial>) => void + positionSelected: (corner: OverlayCorner) => void + removeSelected: () => void + exportImage: (format: ExportFormat) => Promise +} diff --git a/src/utils/editorMath.ts b/src/utils/editorMath.ts new file mode 100644 index 0000000..ddafa89 --- /dev/null +++ b/src/utils/editorMath.ts @@ -0,0 +1,71 @@ +import type { ImageDimensions, OverlayCorner } from '@/types/editor' + +export const MIN_OVERLAY_WIDTH = 12 +export const DEFAULT_OVERLAY_WIDTH_RATIO = 0.22 +export const DEFAULT_MARGIN_RATIO = 0.025 + +export interface OverlayTransform { + x: number + y: number + scale: number + width: number + height: number +} + +export function fitDisplayDimensions( + source: ImageDimensions, + availableWidth: number, +): ImageDimensions { + if (source.width <= 0 || source.height <= 0 || availableWidth <= 0) { + return { width: 0, height: 0 } + } + + const scale = Math.min(1, availableWidth / source.width) + return { + width: Math.max(1, Math.round(source.width * scale)), + height: Math.max(1, Math.round(source.height * scale)), + } +} + +export function clampOverlayTransform( + sourceWidth: number, + sourceHeight: number, + x: number, + y: number, + requestedScale: number, + bounds: ImageDimensions, +): OverlayTransform { + if (sourceWidth <= 0 || sourceHeight <= 0 || bounds.width <= 0 || bounds.height <= 0) { + return { x: 0, y: 0, scale: 1, width: 0, height: 0 } + } + + const maximumScale = Math.min(bounds.width / sourceWidth, bounds.height / sourceHeight) + const minimumScale = Math.min(MIN_OVERLAY_WIDTH / sourceWidth, maximumScale) + const safeRequestedScale = Number.isFinite(requestedScale) ? requestedScale : minimumScale + const scale = Math.min(Math.max(safeRequestedScale, minimumScale), maximumScale) + const width = sourceWidth * scale + const height = sourceHeight * scale + + return { + x: Math.min(Math.max(Number.isFinite(x) ? x : 0, 0), Math.max(0, bounds.width - width)), + y: Math.min(Math.max(Number.isFinite(y) ? y : 0, 0), Math.max(0, bounds.height - height)), + scale, + width, + height, + } +} + +export function positionInCorner( + corner: OverlayCorner, + overlay: Pick, + bounds: ImageDimensions, +): Pick { + const margin = Math.min(bounds.width, bounds.height) * DEFAULT_MARGIN_RATIO + const isRight = corner === 'top-right' || corner === 'bottom-right' + const isBottom = corner === 'bottom-left' || corner === 'bottom-right' + + return { + x: isRight ? Math.max(0, bounds.width - overlay.width - margin) : margin, + y: isBottom ? Math.max(0, bounds.height - overlay.height - margin) : margin, + } +} diff --git a/src/utils/imageFiles.ts b/src/utils/imageFiles.ts new file mode 100644 index 0000000..6b140cf --- /dev/null +++ b/src/utils/imageFiles.ts @@ -0,0 +1,11 @@ +export const SUPPORTED_IMAGE_MIME_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/webp', +]) + +export const SUPPORTED_IMAGE_ACCEPT = 'image/png,image/jpeg,image/webp' + +export function isSupportedImageFile(file: File): boolean { + return SUPPORTED_IMAGE_MIME_TYPES.has(file.type) +} diff --git a/src/utils/overlayConfig.ts b/src/utils/overlayConfig.ts new file mode 100644 index 0000000..2452dcf --- /dev/null +++ b/src/utils/overlayConfig.ts @@ -0,0 +1,43 @@ +import type { OverlayLibraryItem } from '@/types/editor' + +const SUPPORTED_OVERLAY_FILE = /^[A-Za-z0-9][A-Za-z0-9._ -]*\.(?:png|jpe?g|webp)$/i + +function normalizedString(value: unknown): string | null { + if (typeof value !== 'string') return null + const normalized = value.trim() + return normalized || null +} + +export function parseOverlayLibrary(value: unknown): OverlayLibraryItem[] { + if (!Array.isArray(value)) { + throw new Error('Overlay configuration must be an array.') + } + + const ids = new Set() + const files = new Set() + + return value.map((candidate, index) => { + if (!candidate || typeof candidate !== 'object') { + throw new Error(`Overlay entry ${index + 1} is invalid.`) + } + + const entry = candidate as Record + const id = normalizedString(entry.id) + const name = normalizedString(entry.name) + const file = normalizedString(entry.file) + + if (!id || !name || !file || !SUPPORTED_OVERLAY_FILE.test(file) || file.includes('..')) { + throw new Error(`Overlay entry ${index + 1} has invalid fields.`) + } + if (ids.has(id)) { + throw new Error(`Overlay id "${id}" is duplicated.`) + } + if (files.has(file)) { + throw new Error(`Overlay file "${file}" is duplicated.`) + } + + ids.add(id) + files.add(file) + return { id, name, file } + }) +} diff --git a/tests/editorMath.test.ts b/tests/editorMath.test.ts new file mode 100644 index 0000000..ca78945 --- /dev/null +++ b/tests/editorMath.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + clampOverlayTransform, + fitDisplayDimensions, + positionInCorner, +} from '../src/utils/editorMath.ts' + +test('fitDisplayDimensions keeps source coordinates independent from display size', () => { + assert.deepEqual(fitDisplayDimensions({ width: 4032, height: 3024 }, 900), { + width: 900, + height: 675, + }) + assert.deepEqual(fitDisplayDimensions({ width: 800, height: 600 }, 1200), { + width: 800, + height: 600, + }) +}) + +test('clampOverlayTransform keeps the full overlay inside the source image', () => { + const result = clampOverlayTransform(400, 200, 950, -50, 0.5, { + width: 1000, + height: 500, + }) + + assert.equal(result.scale, 0.5) + assert.equal(result.width, 200) + assert.equal(result.height, 100) + assert.equal(result.x, 800) + assert.equal(result.y, 0) +}) + +test('clampOverlayTransform limits oversized overlays without changing aspect ratio', () => { + const result = clampOverlayTransform(2000, 1000, 0, 0, 1, { + width: 1000, + height: 400, + }) + + assert.equal(result.scale, 0.4) + assert.equal(result.width, 800) + assert.equal(result.height, 400) +}) + +test('positionInCorner uses a proportional margin and does not clip the object', () => { + const position = positionInCorner( + 'bottom-right', + { width: 200, height: 100 }, + { width: 1000, height: 500 }, + ) + + assert.equal(position.x, 787.5) + assert.equal(position.y, 387.5) +}) diff --git a/tests/overlayConfig.test.ts b/tests/overlayConfig.test.ts new file mode 100644 index 0000000..15573a8 --- /dev/null +++ b/tests/overlayConfig.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { parseOverlayLibrary } from '../src/utils/overlayConfig.ts' + +test('parseOverlayLibrary accepts simple local raster files', () => { + assert.deepEqual( + parseOverlayLibrary([{ id: 'crr', name: ' CRR Logo ', file: 'crr-logo.png' }]), + [{ id: 'crr', name: 'CRR Logo', file: 'crr-logo.png' }], + ) +}) + +test('parseOverlayLibrary rejects duplicate ids', () => { + assert.throws(() => parseOverlayLibrary([ + { id: 'crr', name: 'One', file: 'one.png' }, + { id: 'crr', name: 'Two', file: 'two.png' }, + ])) +}) + +test('parseOverlayLibrary rejects paths, URLs, and SVG files', () => { + for (const file of ['../logo.png', 'nested/logo.png', 'https://example.test/logo.png', 'logo.svg']) { + assert.throws(() => parseOverlayLibrary([{ id: 'bad', name: 'Bad', file }])) + } +}) diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..5b6619d --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "composite": true, + "target": "ES2022", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "preserve", + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + } +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..734e81d --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,15 @@ +{ + "include": ["vite.config.ts"], + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "composite": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "skipLibCheck": true + } +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..f56e1b2 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,15 @@ +import { fileURLToPath, URL } from 'node:url' +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + // Relative build assets make the static bundle more portable. Docker still + // serves the app at the root of its hostname by default. + base: './', + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, +})