commit 48cef99257b0f965d051f54bfcfedb3158cf6dd0 Author: Ondřej Glaser Date: Wed May 13 15:25:04 2026 +0200 Initial portal commit: landing + 9 AI-powered apps Apps: - dwg-rooms: extract room numbers from DWG/DXF - dwg-counting: count symbols in PDF drawings (OpenCV template matching) - contract-check: review PDF contracts against a checklist (Claude vision + Tesseract OCR fallback) - email-drafter: bullet notes → polished Czech/English business emails - invoice-extractor: PDF/image invoice → structured data → Excel - translator: Czech-first translator across 19 languages with tone control - vv-check: find inconsistent unit prices across VV sheets in one workbook - vv-compare: diff original vs new VV files (changes / added / removed) - feature-request: portal users submit ideas + sample files Infrastructure: - LiteLLM gateway with per-app virtual keys + budgets - Langfuse observability - Geist font, shared theme, cross-subdomain back link + theme sync via cookie/URL - Caddy reverse proxy on *.klas.chat diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0f90d25 --- /dev/null +++ b/.env.example @@ -0,0 +1,26 @@ +# --- Required --- + +# Secret used by Auth.js to sign session JWTs. Generate with: +# openssl rand -base64 32 +AUTH_SECRET=change-me-please + +# Public URL of the portal. In dev this is the Caddy-served URL. +NEXTAUTH_URL=https://ai.klas.chat + +# --- Dev access (Credentials provider) --- +# Single-password gate used while Microsoft Entra is not yet provisioned. +# Leave empty in production to disable the password fallback. +DEV_PORTAL_PASSWORD= + +# --- Microsoft Entra ID (production) --- +# Fill these once an admin provisions an app registration in Entra. +# Leaving them empty hides the "Continue with Microsoft" button. +AUTH_MICROSOFT_ENTRA_ID_ID= +AUTH_MICROSOFT_ENTRA_ID_SECRET= +# Tenant-specific issuer, e.g. https://login.microsoftonline.com//v2.0 +AUTH_MICROSOFT_ENTRA_ID_ISSUER= + +# --- Langfuse (optional, only when you uncomment the langfuse services) --- +# LANGFUSE_DB_PASSWORD= +# LANGFUSE_NEXTAUTH_SECRET= +# LANGFUSE_SALT= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67b3226 --- /dev/null +++ b/.gitignore @@ -0,0 +1,53 @@ +# ── Secrets ────────────────────────────────────────────────── +# Never commit env files — every subapp keeps its own LiteLLM virtual key +# in .env. Use .env.example to document the required variables. +.env +.env.* +!.env.example + +# ── Build artifacts ────────────────────────────────────────── +node_modules/ +.next/ +.turbo/ +.vercel/ +.swc/ +__pycache__/ +*.pyc +*.pyo +*.egg-info/ +build/ +dist/ + +# ── Runtime / volumes ──────────────────────────────────────── +# Bind-mounted Docker volumes and tmp work dirs — never commit user data. +feature-request/data/ +*/tmp/ + +# ── Test / sample artifacts (kept locally, not in repo) ────── +# Add specific samples back to the repo deliberately if needed. +ideas/*.xlsx +ideas/*.pdf +dwg-counting/test*.pdf +dwg-counting/symbol.png +dwg-counting/*.dwg +*.png.backup + +# ── Local Playwright outputs ───────────────────────────────── +.playwright-mcp/ +portal-tiles*.png +portal-cta*.png + +# ── OS / editor ────────────────────────────────────────────── +.DS_Store +Thumbs.db +*.swp +*~ +.idea/ +.vscode/ + +# ── Logs ───────────────────────────────────────────────────── +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/AI_PORTAL_HANDOFF.md b/AI_PORTAL_HANDOFF.md new file mode 100644 index 0000000..c2b8240 --- /dev/null +++ b/AI_PORTAL_HANDOFF.md @@ -0,0 +1,339 @@ +# Internal AI Portal — Project Handoff + +**Status:** Architecture & vendor selection complete. Ready for build. +**Audience:** Local coding agent + project owner. +**Goal:** Build an internal AI landing page for company employees that combines guided "AI apps" (tile-based workflows) with a full-featured chat fallback, all routed through company API keys with per-user cost tracking and budget enforcement. + +--- + +## 1. Product vision + +A single internal URL employees go to. They see: + +- **A grid of tiles**, each a guided "AI app" for a specific task (legal-notice check, contract summarizer, invoice extractor, translator, etc.). These are for colleagues who don't want to write prompts — fill a form, get an answer. +- **One special tile labeled "Open Chat"** that drops them into a full ChatGPT/Claude-equivalent chat: file uploads (Excel, PDF, images), code execution sandbox, multi-turn, switch between models mid-conversation. +- All API calls go through company-owned keys. Employees don't pay; the company does. In return, every call is logged, costed, and budget-capped per user. + +**Non-goals (for v1):** +- Customer-facing product (internal only). +- Multi-tenant SaaS. +- Replacing existing tools that aren't AI-related. + +--- + +## 2. Architecture overview + +Composition of three FOSS components plus a thin custom landing page. No single FOSS project covers all requirements — combining them is the production-ready path. + +``` + ┌────────────────────────────────────┐ + │ Custom landing page (tiles UI) │ + │ - Auth via company SSO (OIDC) │ + │ - Renders tiles + "Open Chat" │ + └────────────┬───────────────────────┘ + │ + ┌──────────────────────┼──────────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌────────────┐ ┌────────────┐ ┌──────────────┐ + │ Dify │ │ Open WebUI │ │ Other tile │ + │ (workflow │ │ (chat + │ │ apps (links │ + │ apps) │ │ sandbox) │ │ or iframes)│ + └─────┬──────┘ └─────┬──────┘ └──────┬───────┘ + │ │ │ + └──────────────────────┼───────────────────────┘ + ▼ + ┌──────────────────────────────┐ + │ LiteLLM proxy gateway │ + │ - Unified OpenAI-format │ + │ - Per-user virtual keys │ + │ - Budget enforcement │ + │ - Cost & token logging │ + └──────────────┬───────────────┘ + │ + ▼ + ┌──────────────────────────────┐ + │ Langfuse (observability) │ + │ - Traces, dashboards │ + │ - Per-user analytics │ + │ (consumes LiteLLM logs) │ + └──────────────┬───────────────┘ + │ + ▼ + OpenAI / Anthropic / Google / Azure APIs +``` + +### Component responsibilities + +| Component | Role | License | +|-----------|------|---------| +| **Landing page** | Tile UI, SSO entry point, hands users off to specific tools | Custom (we own it) | +| **Dify** | Hosts the predefined "apps" — visual workflow builder, each app is a workflow | Apache 2.0 + extras (logo restriction; OK for internal single-tenant) | +| **Open WebUI** | The "Open Chat" experience — full ChatGPT-equivalent UI with file uploads & code interpreter | BSD-3-Clause | +| **gVisor code execution add-on** | Sandboxed Python/Bash for Open WebUI (Excel processing, charts, etc.) | Apache 2.0 | +| **LiteLLM** | Single API gateway for all providers, virtual keys per user, budget enforcement, cost tracking | MIT | +| **Langfuse** | Observability dashboards on top of LiteLLM | MIT (with self-host caveats — see §9) | + +### Why this split + +- **Dify alone** has tile-based apps but its chat is weaker than dedicated chat UIs and its code interpreter doesn't match ChatGPT's Excel-processing experience. +- **Open WebUI alone** is chat-first; building 50 guided "apps" inside it means custom Functions/Pipelines code per app, which is harder to maintain than Dify's visual workflows. +- **LibreChat** was considered but rejected: its built-in code interpreter is a paid SaaS service, which fails the "as capable as official chat interfaces" requirement out of the box. Open WebUI's gVisor sandbox is fully free. +- **LiteLLM** is the only piece that gives us per-user budgets and cost tracking across both Dify and Open WebUI, since both can be configured to use it as their "OpenAI-compatible" provider. + +--- + +## 3. User flows + +### Flow A — colleague who doesn't want to write prompts + +1. Opens portal → sees tile grid. +2. Clicks "Legal Notice Check" tile → opens Dify-hosted app in iframe or new tab. +3. Form: paste notice text, select jurisdiction, click Submit. +4. Dify workflow runs → output displayed. +5. All LLM calls inside the workflow went through LiteLLM → cost attributed to this user. + +### Flow B — colleague who wants a real chat + +1. Opens portal → clicks "Open Chat" tile. +2. Lands in Open WebUI session (already authenticated via SSO). +3. Uploads `q3-sales.xlsx`, asks "summarize regional performance and draw a bar chart." +4. Model writes Python → gVisor sandbox executes → result + chart appear inline. +5. User can switch model mid-conversation (Claude → GPT-5 → local). +6. All calls flow through LiteLLM → costed to this user, budget enforced. + +### Flow C — admin + +1. Opens LiteLLM admin UI → sees per-user spend, sets/adjusts budgets. +2. Opens Langfuse → sees traces, prompt analytics, error rates. +3. Opens Dify admin → adds a new workflow → it appears as a new tile on the landing page (via config update). + +--- + +## 4. Repository layout (proposed) + +``` +ai-portal/ +├── README.md +├── docker-compose.yml # All services +├── .env.example +├── landing/ # Custom landing page (Next.js or similar) +│ ├── package.json +│ ├── src/ +│ │ ├── pages/index.tsx # Tile grid +│ │ ├── lib/auth.ts # OIDC client +│ │ └── data/apps.json # Tile definitions +│ └── Dockerfile +├── litellm/ +│ ├── config.yaml # Models, virtual keys, budgets +│ └── README.md +├── openwebui/ +│ ├── env.example +│ ├── functions/ # Custom OWUI functions if needed +│ └── tools/ +│ └── run_code.py # gVisor sandbox tool (from EtiennePerot/safe-code-execution) +├── dify/ +│ ├── env.example +│ ├── workflows/ # Exported workflow DSL files (one per tile app) +│ │ ├── legal-notice-check.yml +│ │ └── ... +│ └── README.md +├── langfuse/ +│ └── env.example +├── infra/ +│ ├── traefik/ # Reverse proxy + TLS +│ │ └── traefik.yml +│ └── backup/ +│ └── backup.sh +└── docs/ + ├── ARCHITECTURE.md # This document, eventually + ├── ADDING_NEW_APP.md # Runbook for adding a tile + └── ONBOARDING.md # End-user docs +``` + +--- + +## 5. Build phases + +### Phase 0 — Local dev environment (Day 1) + +Goal: all four services running locally via docker-compose, talking to each other. + +1. Bootstrap repo with the layout above. +2. Write `docker-compose.yml` with services: `litellm`, `langfuse`, `openwebui`, `dify-api`, `dify-web`, `dify-worker`, `redis`, `postgres`, `traefik`, `landing`. +3. Each service gets its own subnet + `.env` file. No secrets in git. +4. Verify: each service reachable at `https://.localhost` via Traefik with self-signed certs. + +**Acceptance:** `docker compose up` brings everything up clean. Each service's UI loads. + +### Phase 1 — LiteLLM gateway (Day 2) + +Goal: all model calls go through LiteLLM. Cost tracking works. + +1. Configure `litellm/config.yaml` with at minimum: OpenAI, Anthropic, Google. Add Azure if used. +2. Set up a master key + per-user virtual keys (for now: 2 test users). +3. Set test budget: $5/user/month, hard cap. +4. Smoke test: `curl` a chat completion through LiteLLM → verify it appears in LiteLLM's spend log. +5. Verify budget enforcement: temporarily lower limit, blast 1000 tokens, confirm 429. + +**Acceptance:** LiteLLM admin UI shows per-user spend in real time. Budget limits actually block. + +LiteLLM docs: https://docs.litellm.ai/docs/proxy/quick_start + +### Phase 2 — Open WebUI as the chat tile (Days 3-4) + +Goal: full chat UI with file upload + Excel processing. + +1. Configure Open WebUI to use LiteLLM as its OpenAI-compatible endpoint. +2. Set up SSO (OIDC) so user identity flows through. +3. Map OWUI users → LiteLLM virtual keys (so per-user budgets work). Two options: + - Single LiteLLM key, OWUI passes user ID as `X-LiteLLM-User-Id` header — requires LiteLLM tag-based tracking. + - Per-user LiteLLM keys, OWUI feature for per-user provider keys. Investigate which is cleaner. +4. Install the gVisor code execution function and tool from `EtiennePerot/safe-code-execution`. Configure the OWUI container with sandboxing prerequisites (privileged or specific capabilities — see that repo's setup docs). +5. Test: upload an XLSX, ask the model to analyze and chart it. Verify code runs in sandbox, chart appears inline, no code escapes the sandbox. + +**Acceptance:** A non-technical user can upload a file and ask "what's in this spreadsheet" and get a useful answer with a chart. Cost shows up in LiteLLM tied to that user. + +References: +- Open WebUI docs: https://docs.openwebui.com/ +- Code exec add-on: https://github.com/EtiennePerot/safe-code-execution +- Pyodide alternative (no Docker privilege needed but limited libs): https://docs.openwebui.com/features/chat-conversations/chat-features/code-execution/python/ + +### Phase 3 — Dify for workflow apps (Days 5-7) + +Goal: at least 3 working "apps" hosted in Dify and reachable via shareable URLs. + +1. Configure Dify's model providers to point at LiteLLM (NOT directly at OpenAI/Anthropic). This is critical — otherwise Dify calls bypass cost tracking. +2. Create the first 3 workflows as proof of concept: + - **Legal Notice Check** (input: text + jurisdiction → output: risk summary) + - **Document Summarizer** (input: file → output: bullet summary) + - **Email Drafter** (input: bullets → output: polished email) +3. For each workflow, enable the "share as web app" feature, get a URL. +4. Decide tile-to-workflow URL mapping format: store in `landing/src/data/apps.json`. + +**Acceptance:** Each app works end-to-end via its share URL. Each invocation shows up in LiteLLM spend log attributed to a user (this requires Dify to forward user identity — research how, may need a custom API gateway proxy in front of Dify share URLs). + +References: +- Dify self-hosting: https://docs.dify.ai/getting-started/install-self-hosted/docker-compose +- Dify license: https://github.com/langgenius/dify/blob/main/LICENSE (note logo restriction) + +### Phase 4 — Landing page (Days 8-10) + +Goal: the front door. + +1. Next.js (or Astro, SvelteKit — pick what the team knows) app with: + - OIDC login. + - Tile grid, data-driven from `apps.json`. + - Each tile: icon, title, 1-line description, click-through to either a Dify share URL or `/chat` (Open WebUI). + - Search/filter for when there are 50 tiles. +2. Make `apps.json` editable without redeploy: read it from a mounted volume or fetch from Dify's app list API on render. +3. Branding: company logo, color scheme. +4. Add a simple "Costs" link visible to admins only → embeds Langfuse or LiteLLM dashboard. + +**Acceptance:** A new employee with SSO access lands on the page, sees tiles, can click into a workflow or open chat without any extra login prompt. + +### Phase 5 — Observability & ops (Days 11-12) + +1. Stand up Langfuse, configure LiteLLM to ship traces to it. +2. Build a couple of saved dashboards: total spend per day, top 10 users, top 10 apps. +3. Set up alerts: Slack/email when monthly spend hits 80% of cap. +4. Backup script for: Postgres (Dify, Langfuse, Open WebUI), Redis snapshots, Dify workflow exports. +5. Restore drill: spin up fresh stack from backups in <30 min. + +### Phase 6 — Hardening & rollout (Days 13-15) + +1. Move from self-signed to real TLS (Let's Encrypt via Traefik). +2. Lock down: only company SSO group X can authenticate. +3. Audit log review: confirm every LLM call has user attribution. +4. Doc: `ONBOARDING.md` for end users, `ADDING_NEW_APP.md` for the team that maintains workflows. +5. Pilot with 5-10 users for a week. Iterate. Then announce internally. + +--- + +## 6. Critical decisions to make before coding + +These should be answered before Phase 0: + +1. **Where will this run?** On-prem, AWS, Azure, GCP? Affects networking, secrets management, and TLS. +2. **Which SSO?** Microsoft Entra ID (Azure AD), Google Workspace, Okta, Keycloak? Open WebUI, Dify, and Langfuse all support OIDC; landing page will too. +3. **What's the realistic budget for v1?** Affects: how many models we expose (GPT-5 + Claude Opus = expensive), default per-user budget, alert thresholds. +4. **Who owns this long-term?** The team adding new workflows in Dify is different from the team maintaining the platform. Make this explicit. +5. **Data sensitivity?** If employees may paste PII or confidential data, need to: (a) prefer Anthropic/OpenAI Zero Data Retention agreements, (b) consider Azure OpenAI for stronger contractual posture, (c) document an acceptable use policy. +6. **Logo customization for Dify.** Internal-only single-tenant deployments are fine keeping the Dify logo. If branding is required, pricing requires contacting business@dify.ai directly. Recommend: keep logo for v1, revisit if leadership pushes back. + +--- + +## 7. Risks & mitigations + +| Risk | Mitigation | +|------|------------| +| Cost runs away on day 1 | LiteLLM hard budgets per user enforced from the start, low default ($10-20/user/month) | +| Sandbox escape in code interpreter | gVisor is what ChatGPT uses; combined with container isolation, lowest-feasible risk for this use case. No internet egress from sandbox by default. | +| Dify license violation | Stay single-tenant, keep logo (or pay for license). Document this in `LICENSE_NOTES.md` | +| Sensitive data leakage to providers | Configure providers with ZDR / no-training. Add a banner on the landing page reminding users not to paste secrets. Optionally: a content filter at the LiteLLM layer (regex for credit cards, secrets) that strips/blocks. | +| Vendor lock-in to Dify | Workflows are exportable as YAML. Periodic export commit to git keeps them portable. | +| User identity drift across services | Single OIDC issuer, all services configured to use it, scripted "sync user → LiteLLM virtual key" on first login. | +| Open WebUI gVisor sandbox needs privileged Docker | If host policy blocks privileged containers, fall back to Pyodide (browser-based, no privilege needed, narrower library set). Document the trade-off. | + +--- + +## 8. Acceptance criteria for v1 + +The v1 ships when all of these are true: + +- [ ] An employee with SSO can reach the portal at `https://ai..` and see tiles. +- [ ] At least 5 working "apps" as tiles, plus the chat tile. +- [ ] Chat supports: Excel/CSV/PDF upload, code execution, image input, model switching mid-conversation. +- [ ] Every LLM call appears in LiteLLM logs with the calling user's identity. +- [ ] Per-user monthly budget enforces (verified by integration test). +- [ ] Admin can see a per-user, per-app spend dashboard. +- [ ] Adding a new "app" tile takes <30 minutes (build workflow in Dify, add row to `apps.json`). +- [ ] Documented disaster recovery: backups + restore drill executed once. +- [ ] Acceptable use policy linked from the landing page. + +--- + +## 9. Known unknowns / research items + +These need a spike before committing: + +1. **User identity propagation Dify → LiteLLM.** Dify's "share as web app" mode may not pass end-user identity to its LLM calls. May need to either: (a) put a small reverse proxy in front of Dify share URLs that injects a header, or (b) provision per-user Dify accounts and per-user provider keys in Dify (expensive ops cost). Spike this in Phase 3. +2. **Open WebUI per-user provider keys vs tag-based attribution.** Both work; pick based on which has cleaner UX for budget reporting. +3. **Langfuse self-hosting license.** Langfuse self-hosted has tiers; the FOSS core covers traces but some advanced features are commercial. Confirm the FOSS-core features are sufficient before depending on it. If not, fallback is LiteLLM's built-in dashboard (less pretty but free and sufficient for v1). +4. **Where do uploaded files live?** Open WebUI stores them; depending on data classification, may need to mount an encrypted volume or shorten retention. +5. **Rate limits per provider.** With many users behind one company key, OpenAI/Anthropic org-level RPM/TPM limits may bite. LiteLLM supports load balancing across multiple keys — plan for this if rollout is wide. + +--- + +## 10. Quick start for the local agent + +When you sit down with the local agent, suggested first prompt: + +> Read `AI_PORTAL_HANDOFF.md` in this repo. We're building Phase 0 today: the docker-compose skeleton. Set up the directory structure from Section 4, create a `docker-compose.yml` that starts LiteLLM, Open WebUI, Dify (api + web + worker), Postgres, Redis, Langfuse, Traefik, and a placeholder landing page service. Use environment variables for all secrets, with a `.env.example` checked in. Don't configure model providers yet — just get all containers healthy and reachable through Traefik with self-signed TLS at `*.localhost`. Stop and ask before proceeding to Phase 1. + +After Phase 0 works, hand the agent Phase 1, 2, etc. one at a time. Don't let it run more than one phase ahead — these are integration-heavy and a bad assumption early on will cascade. + +--- + +## Appendix A — Reference links + +- Dify: https://github.com/langgenius/dify +- Open WebUI: https://github.com/open-webui/open-webui +- Open WebUI code exec docs: https://docs.openwebui.com/features/chat-conversations/chat-features/code-execution/ +- gVisor sandbox add-on: https://github.com/EtiennePerot/safe-code-execution +- LiteLLM: https://github.com/BerriAI/litellm +- LiteLLM proxy docs: https://docs.litellm.ai/docs/proxy/quick_start +- Langfuse: https://github.com/langfuse/langfuse + +## Appendix B — Why not LibreChat + +Considered. Rejected because LibreChat's built-in Code Interpreter is a paid SaaS service rather than a self-hostable component. The "as capable as official chat interfaces" requirement specifically calls for in-chat code execution and file processing; making that a paid dependency contradicts the "free and open source" goal. Open WebUI's gVisor add-on covers the same ground and is fully Apache-2.0. + +## Appendix C — Why not building from scratch + +Considered. Rejected for v1 because: +- A production-quality multi-provider chat UI with file uploads, model switching, conversation history, and a sandboxed code interpreter is roughly a year of focused engineering. Open WebUI gives us this for free. +- A workflow builder that non-engineers can use to add new "apps" is similarly large in scope. Dify gives us this for free. +- Custom code is justified only where no FOSS option exists: the landing page (small), the auth glue, and the per-user billing reconciliation. + +If after running v1 for a few months we find the FOSS pieces don't fit, we can replace one component at a time without throwing out the whole stack — that's the benefit of the gateway architecture. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b254bec --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# AI Portal + +Internal landing page that fronts a stack of AI tools (chat + workflow apps), +all routed through company API keys with per-user budgets and observability. + +See `AI_PORTAL_HANDOFF.md` for full architecture and rationale. + +## Phase 0 — what's running + +- **Landing page** (Next.js 16 + Auth.js v5) — this repo, `landing/` +- **Open WebUI** — already running on host (`127.0.0.1:8080`) +- **Dify stack** — already running (`docker_default` network, nginx on `:443/:8090`) +- **LiteLLM** — already running on `localai` network (`:4000`) +- **Langfuse** — not yet (compose stub left in `docker-compose.yml`) + +## Local dev + +```bash +cd landing +cp ../.env.example ../.env # then edit secrets +pnpm install +pnpm dev # http://localhost:3000 +``` + +Set at minimum: +- `AUTH_SECRET` — `openssl rand -base64 32` +- `DEV_PORTAL_PASSWORD` — any string; gates the dev login + +## Production-style run (Docker) + +From the `portal/` directory: + +```bash +docker compose up -d --build +``` + +The landing service binds to `127.0.0.1:3010`. Caddy on the host fronts it at +`https://ai.klas.chat`. + +## Auth + +Two providers, both wired through Auth.js v5: + +1. **Microsoft Entra ID** — production. Activated automatically when + `AUTH_MICROSOFT_ENTRA_ID_ID` and `AUTH_MICROSOFT_ENTRA_ID_SECRET` are set. +2. **Dev Credentials** — single shared password from `DEV_PORTAL_PASSWORD`. + Used only while Entra is not provisioned. Both can coexist; the login page + shows whichever is configured. + +All routes except `/login` and `/api/auth/*` are gated by `proxy.ts`. + +## Adding a new tile + +Edit `landing/src/data/apps.json` and add an entry: + +```json +{ + "id": "my-app", + "title": "My App", + "description": "What it does in one line.", + "category": "Documents", + "icon": "FileText", + "accent": "blue", + "href": "https://dify.klas.chat/app/" +} +``` + +Icon names come from [lucide-react](https://lucide.dev). Accents: +`violet`, `blue`, `cyan`, `emerald`, `amber`, `rose`. Use `"href": "#"` to +mark a tile as "Coming soon". + +## What's next (post Phase 0) + +1. Wire Open WebUI to use LiteLLM as its OpenAI-compatible endpoint. +2. Configure Dify model providers to point at LiteLLM (so spend is attributed). +3. Stand up Langfuse (uncomment in `docker-compose.yml`). +4. Map per-user identity → LiteLLM virtual keys. +5. Provision Microsoft Entra app registration; flip prod auth on. diff --git a/contract-check/.env.example b/contract-check/.env.example new file mode 100644 index 0000000..2b57191 --- /dev/null +++ b/contract-check/.env.example @@ -0,0 +1,5 @@ +# LiteLLM proxy endpoint and key for this app. +# Generate a virtual key via the LiteLLM admin UI (alias: contract-check). +LITELLM_BASE_URL=http://host.docker.internal:4000/v1 +LITELLM_API_KEY=sk-... +LLM_MODEL=anthropic/claude-sonnet-4-20250514 diff --git a/contract-check/Dockerfile b/contract-check/Dockerfile new file mode 100644 index 0000000..cb030ba --- /dev/null +++ b/contract-check/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +# Tesseract + Czech language pack for OCR on scanned PDFs; DejaVu for Czech-glyph +# rendering in the generated summary PDF. +RUN apt-get update && apt-get install -y --no-install-recommends \ + tesseract-ocr \ + tesseract-ocr-ces \ + tesseract-ocr-eng \ + fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /tmp/contract-check + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/contract-check/analyzer.py b/contract-check/analyzer.py new file mode 100644 index 0000000..86f3f17 --- /dev/null +++ b/contract-check/analyzer.py @@ -0,0 +1,150 @@ +"""LLM-based contract analysis against a user-supplied checklist.""" +import io +import json +import logging +import os +from pathlib import Path + +import fitz # PyMuPDF +from PIL import Image +import pytesseract +from openai import AsyncOpenAI + +logger = logging.getLogger(__name__) + +_client: AsyncOpenAI | None = None + + +def _get_client() -> AsyncOpenAI: + global _client + if _client is None: + _client = AsyncOpenAI( + base_url=os.getenv("LITELLM_BASE_URL", "http://host.docker.internal:4000/v1"), + api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"), + ) + return _client + + +MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514") + + +SYSTEM_PROMPT = """Jste expert na české obchodní právo a pomáháte podnikateli posoudit smlouvu, kterou má podepsat. + +Vaším úkolem je analyzovat smlouvu z pohledu PŘÍJEMCE smlouvy (toho, kdo ji má podepsat), nikoliv toho, kdo ji sepsal. Hledáte rizika, nevýhodná ustanovení a věci, na které by si měl dát příjemce pozor. + +Text smlouvy obdržíte rozdělený podle stran — každá strana začíná značkou "--- Strana N ---". U každého citátu MUSÍTE uvést, na které straně se nachází. + +Pro každý bod kontrolního seznamu posoudíte smlouvu a vrátíte JSON s následující strukturou: + +{ + "items": [ + { + "id": "id_polozky", + "status": "ok" | "warning" | "problem" | "missing", + "title": "Krátký nadpis nálezu (3-7 slov)", + "summary": "1-3 věty vysvětlující nález z pohledu příjemce smlouvy", + "excerpts": [ + { + "text": "Přesný citát ze smlouvy (krátký, 5-15 slov, ideálně dohledatelný v PDF)", + "comment": "Stručný komentář k úryvku — co znamená / proč je to relevantní", + "page": <číslo strany, kde se citát nachází (celé číslo)> + } + ] + } + ], + "overall_summary": "Celkové shrnutí 3-5 vět: hlavní rizika a hlavní výhody. ŽÁDNÁ doporučení co dělat.", + "risk_level": "low" | "medium" | "high" +} + +Pravidla: +- Status "ok" = ustanovení je vyvážené a bezpečné pro příjemce +- Status "warning" = stojí za pozornost, ne nutně problém +- Status "problem" = jasně nevýhodné nebo riskantní pro příjemce +- Status "missing" = bod se ve smlouvě nepokrývá (chybí ustanovení) +- Excerpts musí být DOSLOVNÉ citáty ze smlouvy (kvůli zvýraznění v PDF). Pokud nelze citovat (např. status=missing), vraťte prázdné pole []. +- KAŽDÝ citát MUSÍ obsahovat číslo strany ("page"). Stranu poznáte podle značky "--- Strana N ---" před textem. +- NEDÁVEJTE doporučení co s nálezem dělat — uživatel sám ví, co bude řešit. Pouze konstatujte fakta. +- Komentáře piště česky, věcně a stručně. +- Nevymýšlejte si — pokud něco ve smlouvě není, řekněte to. +- Vraťte POUZE JSON, žádný markdown, žádný text okolo.""" + + +async def analyze_contract(pdf_path: Path, checklist_items: list[dict]) -> dict: + """Run LLM analysis on a contract PDF against the given checklist.""" + text, used_ocr = _extract_text(pdf_path) + if not text.strip(): + raise RuntimeError( + "Z PDF se nepodařilo získat žádný text — ani standardním " + "způsobem ani OCR. Zkuste lepší kvalitu skenu nebo PDF s textovou vrstvou." + ) + + checklist_block = "\n".join( + f"- id={it['id']}: {it['label']} — {it.get('hint', '')}" + for it in checklist_items + ) + + user_msg = f"""KONTROLNÍ SEZNAM (co posoudit): +{checklist_block} + +TEXT SMLOUVY: +{text} + +Vraťte JSON s analýzou každé položky kontrolního seznamu.""" + + resp = await _get_client().chat.completions.create( + model=MODEL, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_msg}, + ], + temperature=0.1, + max_tokens=8000, + ) + raw = (resp.choices[0].message.content or "").strip() + logger.info("Analysis raw length: %d chars", len(raw)) + # Strip markdown fences if model wrapped it anyway + raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + logger.error("JSON parse failed: %s\n%s", e, raw[:1000]) + raise RuntimeError(f"Nepodařilo se zpracovat odpověď AI: {e}") + # Tell the caller whether we had to OCR — UI can show a notice and + # we'll skip in-page highlighting since the original PDF has no text layer. + data["_used_ocr"] = used_ocr + return data + + +def _extract_text(pdf_path: Path) -> tuple[str, bool]: + """Extract text from PDF. Falls back to OCR if the PDF has no text layer. + + Returns (text, used_ocr). + """ + doc = fitz.open(str(pdf_path)) + parts = [] + total_chars = 0 + for i, page in enumerate(doc): + t = page.get_text() + parts.append(f"--- Strana {i + 1} ---\n{t}") + total_chars += len(t.strip()) + # Heuristic: <20 chars per page on average → looks like a scan + needs_ocr = doc.page_count > 0 and (total_chars / max(doc.page_count, 1)) < 20 + if not needs_ocr: + doc.close() + return "\n\n".join(parts), False + + logger.info("PDF appears to be scanned (~%d chars across %d pages) — running OCR", + total_chars, doc.page_count) + parts = [] + for i, page in enumerate(doc): + # Rasterize page at 250 DPI for OCR quality + pix = page.get_pixmap(dpi=250, alpha=False) + img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples) + try: + ocr_text = pytesseract.image_to_string(img, lang="ces+eng") + except pytesseract.TesseractError as e: + logger.error("Tesseract failed on page %d: %s", i + 1, e) + ocr_text = "" + parts.append(f"--- Strana {i + 1} ---\n{ocr_text}") + doc.close() + return "\n\n".join(parts), True diff --git a/contract-check/checklist.py b/contract-check/checklist.py new file mode 100644 index 0000000..e252903 --- /dev/null +++ b/contract-check/checklist.py @@ -0,0 +1,98 @@ +"""Default checklist of items to review in a Czech business contract. + +Each item targets a risk that disadvantages the RECIPIENT of the contract +(the party being asked to sign), not the drafter. +""" + +DEFAULT_CHECKLIST = [ + { + "id": "sankce", + "label": "Smluvní pokuty a sankce", + "hint": "Jsou pokuty vyvážené? Stejné pro obě strany? Není výše nepřiměřená?", + "default": True, + }, + { + "id": "odpovednost", + "label": "Omezení odpovědnosti za škodu", + "hint": "Je odpovědnost druhé strany omezena nepřiměřeně nízkou částkou? Vyloučení následných škod?", + "default": True, + }, + { + "id": "vypovedni_lhuta", + "label": "Výpovědní lhůta a podmínky výpovědi", + "hint": "Je délka přiměřená? Jsou důvody pro výpověď spravedlivé? Jednostranné podmínky?", + "default": True, + }, + { + "id": "auto_prodlouzeni", + "label": "Automatické prodloužení smlouvy", + "hint": "Hrozí, že se smlouva sama prodlouží? Lhůta pro odmítnutí prodloužení?", + "default": True, + }, + { + "id": "cena_eskalace", + "label": "Cena, indexace a její navyšování", + "hint": "Může druhá strana cenu jednostranně zvyšovat? Vazba na inflaci/jiný index?", + "default": True, + }, + { + "id": "platebni_podminky", + "label": "Platební podmínky a úroky z prodlení", + "hint": "Splatnost faktur, výše úroků z prodlení, předfaktury, zálohy.", + "default": True, + }, + { + "id": "mlcenlivost", + "label": "Mlčenlivost (NDA)", + "hint": "Jednostranná? Doba trvání? Co spadá pod důvěrné informace?", + "default": True, + }, + { + "id": "gdpr", + "label": "Ochrana osobních údajů (GDPR)", + "hint": "Zpracování osobních údajů, role správce/zpracovatele, dodatek o zpracování (DPA).", + "default": True, + }, + { + "id": "ip_prava", + "label": "Duševní vlastnictví a licence", + "hint": "Komu patří výsledky? Rozsah licence? Možnost převodu?", + "default": True, + }, + { + "id": "rozhodne_pravo", + "label": "Rozhodné právo a místo soudu", + "hint": "České právo? Příslušnost soudu? Rozhodčí doložka?", + "default": True, + }, + { + "id": "force_majeure", + "label": "Vyšší moc (force majeure)", + "hint": "Jaké události spadají? Jsou nepřiměřeně omezené?", + "default": False, + }, + { + "id": "postoupeni", + "label": "Postoupení smlouvy třetí straně", + "hint": "Může druhá strana smlouvu postoupit bez vašeho souhlasu?", + "default": False, + }, + { + "id": "exkluzivita", + "label": "Exkluzivita a zákaz konkurence", + "hint": "Zavazujete se nepracovat s konkurencí? Po dobu trvání i po skončení?", + "default": False, + }, + { + "id": "zaruka_kvalita", + "label": "Záruka a reklamace", + "hint": "Délka záruky, postup reklamace, vyloučení odpovědnosti za vady.", + "default": False, + }, + { + "id": "zmeny_smlouvy", + "label": "Změny smlouvy a dodatky", + "hint": "Pouze písemně? Souhlas obou stran? Jednostranné úpravy?", + "default": False, + }, +] diff --git a/contract-check/docker-compose.yml b/contract-check/docker-compose.yml new file mode 100644 index 0000000..fa9d666 --- /dev/null +++ b/contract-check/docker-compose.yml @@ -0,0 +1,24 @@ +services: + contract-check: + build: . + container_name: contract-check + restart: unless-stopped + ports: + - "127.0.0.1:3027:8000" + environment: + LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://host.docker.internal:4000/v1} + LITELLM_API_KEY: ${LITELLM_API_KEY:-} + LLM_MODEL: ${LLM_MODEL:-anthropic/claude-sonnet-4-20250514} + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - localai + volumes: + - contract-check-data:/tmp/contract-check + +volumes: + contract-check-data: + +networks: + localai: + external: true diff --git a/contract-check/main.py b/contract-check/main.py new file mode 100644 index 0000000..e001ab4 --- /dev/null +++ b/contract-check/main.py @@ -0,0 +1,134 @@ +"""FastAPI app: upload contract PDF → analyze against checklist → annotated PDF.""" +import asyncio +import logging +import os +import uuid +from pathlib import Path + +from fastapi import FastAPI, File, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from analyzer import analyze_contract +from checklist import DEFAULT_CHECKLIST +from pdf_annotator import annotate + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="Contract Terms Check") +app.add_middleware(CORSMiddleware, allow_origins=["*"], + allow_methods=["*"], allow_headers=["*"]) + +WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/contract-check")) +WORK_DIR.mkdir(parents=True, exist_ok=True) + +jobs: dict[str, dict] = {} + + +@app.get("/") +async def root(): + return FileResponse("static/index.html") + + +@app.get("/api/checklist") +async def get_checklist(): + """Default checklist items the UI can pre-populate.""" + return {"items": DEFAULT_CHECKLIST} + + +@app.post("/api/upload") +async def upload(file: UploadFile = File(...)): + suffix = Path(file.filename or "").suffix.lower() + if suffix != ".pdf": + raise HTTPException(400, "Podporovaný formát: .pdf") + + job_id = str(uuid.uuid4()) + job_dir = WORK_DIR / job_id + job_dir.mkdir() + + input_path = job_dir / "input.pdf" + raw = await file.read() + input_path.write_bytes(raw) + logger.info("Job %s: %s (%d bytes)", job_id, file.filename, len(raw)) + + jobs[job_id] = { + "filename": file.filename, + "job_dir": str(job_dir), + "input_path": str(input_path), + "analysis": None, + "checklist": None, + } + return {"job_id": job_id} + + +class AnalyzeRequest(BaseModel): + items: list[dict] # [{id, label, hint?, default?}] + + +@app.post("/api/analyze/{job_id}") +async def analyze(job_id: str, req: AnalyzeRequest): + if job_id not in jobs: + raise HTTPException(404, "Úloha nenalezena") + if not req.items: + raise HTTPException(400, "Vyberte alespoň jednu položku ke kontrole") + job = jobs[job_id] + input_path = Path(job["input_path"]) + try: + analysis = await analyze_contract(input_path, req.items) + except Exception as exc: + logger.exception("Analysis failed") + raise HTTPException(500, str(exc)) + # Merge LLM-returned items with the original checklist labels so the UI + # can show the user-facing label even if the LLM was terse. + labels_by_id = {it["id"]: it["label"] for it in req.items} + for it in analysis.get("items", []): + if "label" not in it and it.get("id") in labels_by_id: + it["label"] = labels_by_id[it["id"]] + job["analysis"] = analysis + job["checklist"] = req.items + job["used_ocr"] = bool(analysis.get("_used_ocr")) + return analysis + + +@app.get("/api/annotated/{job_id}") +async def annotated_pdf(job_id: str): + if job_id not in jobs: + raise HTTPException(404, "Úloha nenalezena") + job = jobs[job_id] + if not job.get("analysis"): + raise HTTPException(400, "Nejprve spusťte analýzu") + input_path = Path(job["input_path"]) + out_path = Path(job["job_dir"]) / "annotated.pdf" + analysis = job["analysis"] + # For OCR-only contracts the original PDF has no text layer; skip the + # excerpt-search step so we don't waste time on guaranteed misses. + skip_highlights = bool(job.get("used_ocr")) + try: + await asyncio.to_thread( + annotate, input_path, out_path, + analysis.get("items", []), + analysis.get("overall_summary", ""), + analysis.get("risk_level", ""), + skip_highlights, + job.get("filename") or "", + ) + except Exception as exc: + logger.exception("Annotation failed") + raise HTTPException(500, f"Anotace selhala: {exc}") + stem = Path(job["filename"]).stem if job.get("filename") else "smlouva" + return FileResponse( + str(out_path), + media_type="application/pdf", + filename=f"kontrola_{stem}.pdf", + ) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/contract-check/pdf_annotator.py b/contract-check/pdf_annotator.py new file mode 100644 index 0000000..b553bab --- /dev/null +++ b/contract-check/pdf_annotator.py @@ -0,0 +1,257 @@ +"""Add color-coded highlights and a Czech-correct summary page to a PDF.""" +import logging +from pathlib import Path + +import fitz # PyMuPDF + +logger = logging.getLogger(__name__) + +# RGB 0-1 for PyMuPDF +COLORS = { + "ok": (0.69, 0.91, 0.69), # green + "warning": (1.00, 0.90, 0.45), # yellow + "problem": (1.00, 0.65, 0.65), # red + "missing": (0.85, 0.85, 0.85), # grey +} + +# DejaVu Sans is shipped via fonts-dejavu-core; supports full Czech glyph set. +FONT_PATH_SANS = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" +FONT_PATH_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" + + +def annotate(input_pdf: Path, output_pdf: Path, items: list[dict], + overall_summary: str = "", risk_level: str = "", + skip_highlights: bool = False, + contract_name: str = "") -> Path: + """Open input_pdf, highlight excerpts, prepend a summary page.""" + doc = fitz.open(str(input_pdf)) + + highlighted_count = 0 + not_found_count = 0 + items_to_annotate = [] if skip_highlights else items + if skip_highlights: + logger.info("Skipping per-excerpt highlights (OCR'd PDF — no text layer)") + for item in items_to_annotate: + color = COLORS.get(item.get("status", "warning"), COLORS["warning"]) + title = item.get("title") or item.get("label") or item.get("id", "") + for ex in item.get("excerpts") or []: + quote = (ex.get("text") or "").strip() + comment = (ex.get("comment") or "").strip() + if not quote: + continue + found_any = False + for page in doc: + rects = page.search_for(quote, quads=False) + if not rects and len(quote) > 20: + rects = page.search_for(quote[:20], quads=False) + if not rects: + continue + for rect in rects: + annot = page.add_highlight_annot(rect) + annot.set_colors(stroke=color) + annot.set_info( + title=title, + content=f"{title}\n\n{comment}" if comment else title, + ) + annot.update() + found_any = True + if found_any: + highlighted_count += 1 + else: + not_found_count += 1 + logger.info("Quote not found in PDF: %r", quote[:60]) + + # Build & prepend summary + summary = _build_summary_pdf(doc, items, overall_summary, risk_level, + contract_name) + if summary: + doc.insert_pdf(summary, start_at=0) + summary.close() + + # Set PDF metadata title for nice display in viewers + if contract_name: + meta = doc.metadata or {} + meta["title"] = f"Kontrola: {contract_name}" + doc.set_metadata(meta) + + doc.save(str(output_pdf), garbage=4, deflate=True) + doc.close() + logger.info("Annotated PDF: highlighted=%d not_found=%d", highlighted_count, not_found_count) + return output_pdf + + +def _build_summary_pdf(orig_doc, items: list[dict], + overall_summary: str, risk_level: str, + contract_name: str): + """Build a 1-N page summary PDF using a Czech-supporting font.""" + if not orig_doc.page_count: + return None + src_rect = orig_doc[0].rect + width = max(float(src_rect.width), 595.0) # ensure at least A4 + height = max(float(src_rect.height), 842.0) + + new = fitz.open() + page = new.new_page(width=width, height=height) + _register_fonts(page) + + margin_x = 50.0 + y = 50.0 + title_size = 18 + body_size = 10.5 + line_h = body_size * 1.45 + + # Header line: filename + if contract_name: + _draw_text(page, contract_name, margin_x, y, font="sans", + size=11, color=(0.40, 0.45, 0.55)) + y += 18 + + # Title + _draw_text(page, "Kontrola smluvních podmínek", margin_x, y, + font="bold", size=title_size, color=(0.06, 0.10, 0.20)) + y += title_size * 1.6 + + # Risk badge line + if risk_level: + labels = {"low": "NÍZKÉ", "medium": "STŘEDNÍ", "high": "VYSOKÉ"} + colors = { + "low": (0.20, 0.65, 0.32), + "medium": (0.85, 0.55, 0.10), + "high": (0.80, 0.20, 0.20), + } + _draw_text(page, f"Celková míra rizika: {labels.get(risk_level, risk_level.upper())}", + margin_x, y, font="bold", size=12, + color=colors.get(risk_level, (0.4, 0.4, 0.4))) + y += 22 + + # Overall summary + if overall_summary: + y = _wrap_text(page, overall_summary, margin_x, y, + width - 2 * margin_x, body_size, font="sans") + y += 12 + + y += 6 + page.draw_line((margin_x, y), (width - margin_x, y), + color=(0.85, 0.85, 0.85)) + y += 16 + + _draw_text(page, "Položky kontroly", margin_x, y, + font="bold", size=12, color=(0.15, 0.20, 0.30)) + y += 18 + + status_labels = {"ok": "OK", "warning": "POZOR", + "problem": "PROBLÉM", "missing": "CHYBÍ"} + for item in items: + # Need new page? + if y > height - 80: + page = new.new_page(width=width, height=height) + _register_fonts(page) + y = 50 + + status = item.get("status", "") + color = COLORS.get(status, (0.6, 0.6, 0.6)) + label = status_labels.get(status, status.upper()) + title = item.get("title") or item.get("label") or item.get("id", "") + + # Colored bullet square + page.draw_rect( + fitz.Rect(margin_x, y, margin_x + 10, y + 10), + color=color, fill=color, + ) + _draw_text(page, f"[{label}] {title}", + margin_x + 18, y, font="bold", size=11, + color=(0.06, 0.10, 0.20)) + y += line_h + 4 + + summary = item.get("summary", "") + if summary: + y = _wrap_text(page, summary, margin_x + 18, y, + width - 2 * margin_x - 18, body_size, + font="sans", color=(0.30, 0.35, 0.45)) + + # List page references for each excerpt + excerpts = item.get("excerpts") or [] + if excerpts: + for ex in excerpts: + pg = ex.get("page") + text = (ex.get("text") or "").strip() + if not text: + continue + pg_str = f"str. {pg}: " if pg else "" + snippet = text if len(text) <= 90 else text[:87] + "…" + y = _wrap_text(page, f"• {pg_str}„{snippet}\"", + margin_x + 18, y, + width - 2 * margin_x - 18, body_size - 0.5, + font="sans", color=(0.25, 0.30, 0.40)) + cmt = (ex.get("comment") or "").strip() + if cmt: + y = _wrap_text(page, f" — {cmt}", + margin_x + 18, y, + width - 2 * margin_x - 18, body_size - 0.5, + font="sans", color=(0.45, 0.50, 0.60)) + + y += 12 + + return new + + +# ── font helpers ───────────────────────────────────────── + +def _register_fonts(page): + """Insert DejaVu Sans (regular + bold) on the page if available.""" + try: + page.insert_font(fontname="sans", fontfile=FONT_PATH_SANS) + except Exception as e: + logger.warning("Could not register DejaVuSans: %s", e) + try: + page.insert_font(fontname="bold", fontfile=FONT_PATH_BOLD) + except Exception: + # Fall back to regular for bold + try: + page.insert_font(fontname="bold", fontfile=FONT_PATH_SANS) + except Exception: + pass + + +def _draw_text(page, text: str, x: float, y: float, + font: str = "sans", size: float = 10.5, + color: tuple = (0.06, 0.10, 0.20)): + """Render a single line at baseline y+size.""" + try: + page.insert_text((x, y + size), text, + fontname=font, fontsize=size, color=color) + except Exception: + # Fallback to PyMuPDF built-in (may mangle diacritics but won't crash) + page.insert_text((x, y + size), text, + fontsize=size, color=color) + + +def _wrap_text(page, text: str, x: float, y: float, max_width: float, + font_size: float, font: str = "sans", + color: tuple = (0.06, 0.10, 0.20)) -> float: + """Word-wrap `text` and return the new y position.""" + line_h = font_size * 1.45 + # PyMuPDF has Page.get_text_length() for width calculation + def measure(s: str) -> float: + try: + return fitz.get_text_length(s, fontname=font, fontsize=font_size) + except Exception: + return len(s) * font_size * 0.50 + + words = text.split() + if not words: + return y + line = "" + for word in words: + candidate = (line + " " + word).strip() + if measure(candidate) > max_width and line: + _draw_text(page, line, x, y, font=font, + size=font_size, color=color) + y += line_h + line = word + else: + line = candidate + if line: + _draw_text(page, line, x, y, font=font, size=font_size, color=color) + y += line_h + return y diff --git a/contract-check/requirements.txt b/contract-check/requirements.txt new file mode 100644 index 0000000..40b2d2e --- /dev/null +++ b/contract-check/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +PyMuPDF>=1.24 +pytesseract>=0.3.10 +Pillow>=10.0 +python-multipart>=0.0.9 +openai>=1.50 +python-dotenv>=1.0 +aiofiles>=23.0 diff --git a/contract-check/static/app.js b/contract-check/static/app.js new file mode 100644 index 0000000..1124b1f --- /dev/null +++ b/contract-check/static/app.js @@ -0,0 +1,264 @@ +// Contract check frontend +(() => { + const $ = (id) => document.getElementById(id); + const sections = { + setup: $("s-setup"), + processing: $("s-processing"), + results: $("s-results"), + }; + const show = (name) => { + for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name); + }; + + let state = { + jobId: null, + checklist: [], + analysis: null, + pendingFile: null, // File chosen by user but not yet uploaded + }; + let nextCustomId = 1; + + // ── Load default checklist ── + async function loadChecklist() { + try { + const r = await fetch("/api/checklist"); + const data = await r.json(); + state.checklist = (data.items || []).map((it) => ({ + ...it, + checked: it.default !== false, + })); + renderChecklist(); + } catch (err) { + alert("Nepodařilo se načíst kontrolní seznam: " + err.message); + } + } + + function renderChecklist() { + const c = $("checklist"); + c.innerHTML = ""; + for (const item of state.checklist) { + const div = document.createElement("label"); + div.className = "checklist-item" + (item.custom ? " custom" : ""); + div.innerHTML = ` + +
+
${escapeHtml(item.label)}
+ ${item.hint ? `
${escapeHtml(item.hint)}
` : ""} +
+ ${item.custom ? `` : ""} + `; + const cb = div.querySelector("input"); + cb.addEventListener("change", () => { + item.checked = cb.checked; + updateRunButton(); + }); + const rm = div.querySelector(".checklist-remove"); + if (rm) { + rm.addEventListener("click", (e) => { + e.preventDefault(); + state.checklist = state.checklist.filter((x) => x.id !== item.id); + renderChecklist(); + updateRunButton(); + }); + } + c.appendChild(div); + } + updateRunButton(); + } + + $("check-all-btn").addEventListener("click", () => { + for (const it of state.checklist) it.checked = true; + renderChecklist(); + }); + $("uncheck-all-btn").addEventListener("click", () => { + for (const it of state.checklist) it.checked = false; + renderChecklist(); + }); + + $("add-item-form").addEventListener("submit", (e) => { + e.preventDefault(); + const input = $("add-item-input"); + const label = input.value.trim(); + if (!label) return; + state.checklist.push({ + id: `custom_${nextCustomId++}`, + label, + hint: "", + checked: true, + custom: true, + }); + input.value = ""; + renderChecklist(); + }); + + // ── File selection (just stores the file, doesn't upload yet) ── + const fileInput = $("file-input"); + const dropZone = $("drop-zone"); + $("browse-btn").addEventListener("click", () => fileInput.click()); + fileInput.addEventListener("change", (e) => e.target.files[0] && setFile(e.target.files[0])); + ["dragenter", "dragover"].forEach((ev) => + dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); })); + ["dragleave", "drop"].forEach((ev) => + dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); })); + dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && setFile(e.dataTransfer.files[0])); + + function setFile(file) { + state.pendingFile = file; + $("file-info-name").textContent = file.name; + $("file-info").classList.remove("hidden"); + dropZone.classList.add("compact"); + updateRunButton(); + } + + $("file-info-clear").addEventListener("click", () => { + state.pendingFile = null; + fileInput.value = ""; + $("file-info").classList.add("hidden"); + dropZone.classList.remove("compact"); + updateRunButton(); + }); + + function updateRunButton() { + const hasFile = !!state.pendingFile; + const selectedCount = state.checklist.filter((it) => it.checked).length; + const btn = $("run-btn"); + const hint = $("run-hint"); + // run-hint only exists after a file is selected (lives in the file-info strip) + if (!hasFile) { + btn.disabled = true; + return; + } + if (selectedCount === 0) { + btn.disabled = true; + if (hint) hint.textContent = "Vyberte alespoň jednu položku ke kontrole níže."; + } else { + btn.disabled = false; + if (hint) hint.textContent = `Připraveno spustit kontrolu ${selectedCount} bodů.`; + } + } + + $("run-btn").addEventListener("click", async () => { + if (!state.pendingFile) return; + const selected = state.checklist.filter((it) => it.checked); + if (!selected.length) return; + show("processing"); + $("processing-title").textContent = "Nahrávám smlouvu…"; + try { + const fd = new FormData(); + fd.append("file", state.pendingFile); + const ur = await fetch("/api/upload", { method: "POST", body: fd }); + if (!ur.ok) throw new Error((await ur.json()).detail || ur.statusText); + const upJson = await ur.json(); + state.jobId = upJson.job_id; + + $("processing-title").textContent = `Analyzuji smlouvu (${selected.length} bodů)…`; + const ar = await fetch(`/api/analyze/${state.jobId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + items: selected.map((it) => ({ + id: it.id, + label: it.label, + hint: it.hint || "", + })), + }), + }); + if (!ar.ok) throw new Error((await ar.json()).detail || ar.statusText); + state.analysis = await ar.json(); + renderResults(); + show("results"); + } catch (err) { + alert("Chyba: " + err.message); + show("setup"); + } + }); + + function renderResults() { + const a = state.analysis; + const items = a.items || []; + const usedOcr = !!a._used_ocr; + const counts = items.reduce((acc, it) => { + acc[it.status || "warning"] = (acc[it.status || "warning"] || 0) + 1; + return acc; + }, {}); + $("results-meta").textContent = + `Vyhodnoceno ${items.length} bodů. ` + + `OK: ${counts.ok || 0}, Pozor: ${counts.warning || 0}, ` + + `Problém: ${counts.problem || 0}, Chybí: ${counts.missing || 0}.`; + + const riskMap = { + low: ["Nízké riziko", "risk-low"], + medium: ["Střední riziko", "risk-medium"], + high: ["Vysoké riziko", "risk-high"], + }; + const r = riskMap[a.risk_level] || ["—", ""]; + const overall = $("overall-card"); + const ocrNotice = usedOcr + ? `
⚙ Smlouva neměla textovou vrstvu — použito OCR (rozpoznávání textu z obrazu). Stažené PDF bude obsahovat jen souhrnnou stránku, bez zvýraznění v původním textu (kvalita OCR neumožňuje spolehlivé vyhledání citací).
` + : ""; + overall.innerHTML = ` +
${r[0]}
+
+ ${escapeHtml(a.overall_summary || "")} + ${ocrNotice} +
+ `; + + const findings = $("findings"); + findings.innerHTML = ""; + const labelMap = { ok: "OK", warning: "POZOR", problem: "PROBLÉM", missing: "CHYBÍ" }; + // Sort: problem > warning > missing > ok + const order = { problem: 0, warning: 1, missing: 2, ok: 3 }; + const sorted = [...items].sort((a, b) => + (order[a.status] ?? 9) - (order[b.status] ?? 9) + ); + for (const it of sorted) { + const div = document.createElement("div"); + div.className = `finding status-${it.status || "warning"}`; + const excerptsHtml = (it.excerpts || []).map((ex) => ` +
+
+ ${ex.page ? `str. ${ex.page}` : ""} + „${escapeHtml(ex.text || "")}" +
+ ${ex.comment ? `
${escapeHtml(ex.comment)}
` : ""} +
+ `).join(""); + div.innerHTML = ` +
+ ${labelMap[it.status] || it.status || ""} + ${escapeHtml(it.title || it.label || it.id)} +
+ ${it.summary ? `

${escapeHtml(it.summary)}

` : ""} + ${excerptsHtml ? `
${excerptsHtml}
` : ""} + `; + findings.appendChild(div); + } + } + + $("download-pdf-btn").addEventListener("click", () => { + if (!state.jobId) return; + window.location.href = `/api/annotated/${state.jobId}`; + }); + + $("restart-btn").addEventListener("click", () => { + state = { + jobId: null, + checklist: state.checklist, + analysis: null, + pendingFile: null, + }; + fileInput.value = ""; + $("file-info").classList.add("hidden"); + dropZone.classList.remove("compact"); + show("setup"); + updateRunButton(); + }); + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => + ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); + } + + loadChecklist(); +})(); diff --git a/contract-check/static/extra.css b/contract-check/static/extra.css new file mode 100644 index 0000000..320d673 --- /dev/null +++ b/contract-check/static/extra.css @@ -0,0 +1,341 @@ +/* Contract-check specific styles */ + +/* Compact drop zone after a file is selected (still allows replacing) */ +.drop-zone.compact { + padding: 16px 24px; +} +.drop-zone.compact .drop-icon, +.drop-zone.compact .drop-text, +.drop-zone.compact .drop-or, +.drop-zone.compact .drop-formats { + display: none; +} + +.file-info { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 18px; + margin: 12px 0 24px; + background: var(--card); + border: 1px solid var(--border-default); + border-left: 3px solid var(--primary); + border-radius: 8px; + font-size: 14px; + color: var(--text-primary); + position: sticky; + top: 64px; /* sits just below the sticky header */ + z-index: 10; + box-shadow: var(--shadow-card); +} +.file-info svg { color: var(--primary); flex-shrink: 0; } +.file-info-text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + overflow: hidden; +} +.file-info-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.file-info-hint { + font-size: 12px; + color: var(--text-tertiary); +} +.file-info .btn-link { flex-shrink: 0; } +.file-info .btn { flex-shrink: 0; } + +@media (max-width: 640px) { + .file-info { flex-wrap: wrap; } + .file-info-text { flex-basis: 100%; } +} +.btn-link { + background: transparent; + border: none; + color: var(--primary); + font-size: 13px; + cursor: pointer; + padding: 4px 8px; + border-radius: 6px; + flex-shrink: 0; +} +.btn-link:hover { background: color-mix(in srgb, var(--primary) 8%, transparent); } + +.run-row { + display: flex; + align-items: center; + gap: 14px; + margin-top: 22px; + padding-top: 22px; + border-top: 1px solid var(--border-default); + flex-wrap: wrap; +} +.btn-lg { + padding: 12px 24px; + font-size: 14px; + font-weight: 600; +} +.run-hint { + font-size: 13px; + color: var(--text-tertiary); +} + + +.checklist-panel { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 18px; + margin-bottom: 24px; +} +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 14px; + gap: 12px; +} +.panel-title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary); + letter-spacing: -0.01em; +} +.panel-actions { display: flex; gap: 6px; } +.btn-sm { padding: 4px 10px; font-size: 12px; } + +.checklist { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px 16px; + margin-bottom: 14px; +} +@media (max-width: 720px) { + .checklist { grid-template-columns: 1fr; } +} +.checklist-item { + display: flex; + gap: 8px; + align-items: flex-start; + padding: 8px 10px; + border-radius: 8px; + cursor: pointer; + transition: background 0.12s; +} +.checklist-item:hover { background: var(--bg-tertiary); } +.checklist-item input[type=checkbox] { + width: 16px; + height: 16px; + margin-top: 2px; + accent-color: var(--primary); + cursor: pointer; + flex-shrink: 0; +} +.checklist-item-body { flex: 1; min-width: 0; } +.checklist-item-label { + font-size: 13.5px; + font-weight: 500; + color: var(--text-primary); + line-height: 1.35; +} +.checklist-item-hint { + font-size: 11.5px; + color: var(--text-tertiary); + margin-top: 2px; + line-height: 1.4; +} +.checklist-item.custom .checklist-item-label::after { + content: " vlastní"; + font-size: 10px; + color: var(--primary); + background: color-mix(in srgb, var(--primary) 12%, transparent); + padding: 1px 6px; + border-radius: 999px; + margin-left: 6px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; +} +.checklist-remove { + background: transparent; + border: none; + color: var(--text-quaternary); + cursor: pointer; + font-size: 14px; + padding: 0 4px; + align-self: center; +} +.checklist-remove:hover { color: #dc2626; } + +.add-item-row { + display: flex; + gap: 8px; + border-top: 1px solid var(--border-default); + padding-top: 14px; +} +.add-item-input { + flex: 1; + padding: 8px 12px; + border: 1px solid var(--border-default); + background: var(--bg-primary); + color: var(--text-primary); + border-radius: 8px; + font-size: 13px; + font-family: inherit; +} +.add-item-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} + +.processing-sub { + font-size: 13px; + color: var(--text-tertiary); + margin: 8px auto 0; + max-width: 480px; +} + +/* Results */ +.overall-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 20px 22px; + margin: 16px 0 22px; + display: flex; + gap: 18px; + align-items: flex-start; +} +.risk-badge { + flex-shrink: 0; + padding: 8px 14px; + border-radius: 8px; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.risk-low { background: rgba(34, 197, 94, 0.12); color: #15803d; } +.risk-medium { background: rgba(245, 158, 11, 0.15); color: #b45309; } +.risk-high { background: rgba(239, 68, 68, 0.12); color: #b91c1c; } +.overall-text { + font-size: 14px; + line-height: 1.55; + color: var(--text-secondary); +} +.ocr-notice { + margin-top: 12px; + padding: 10px 14px; + background: rgba(245, 158, 11, 0.08); + border-left: 3px solid #f59e0b; + border-radius: 6px; + font-size: 12.5px; + color: var(--text-secondary); +} + +.findings { + display: flex; + flex-direction: column; + gap: 10px; +} +.finding { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 14px 18px; + border-left: 4px solid var(--border-default); +} +.finding.status-ok { border-left-color: #22c55e; } +.finding.status-warning { border-left-color: #f59e0b; } +.finding.status-problem { border-left-color: #ef4444; } +.finding.status-missing { border-left-color: #94a3b8; } + +.finding-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} +.finding-status { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.06em; + padding: 3px 8px; + border-radius: 999px; + text-transform: uppercase; +} +.status-ok .finding-status { background: rgba(34,197,94,0.14); color: #15803d; } +.status-warning .finding-status { background: rgba(245,158,11,0.16); color: #b45309; } +.status-problem .finding-status { background: rgba(239,68,68,0.14); color: #b91c1c; } +.status-missing .finding-status { background: rgba(148,163,184,0.20); color: #475569; } + +.finding-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} +.finding-summary { + font-size: 13px; + color: var(--text-secondary); + line-height: 1.55; + margin: 4px 0 0; +} +.finding-excerpts { + margin-top: 10px; + display: flex; + flex-direction: column; + gap: 6px; +} +.excerpt { + font-size: 12.5px; + background: var(--bg-tertiary); + padding: 8px 12px; + border-radius: 6px; + border-left: 3px solid var(--border-strong); +} +.excerpt-text { + color: var(--text-primary); + font-style: italic; +} +.excerpt-comment { + margin-top: 4px; + font-size: 12px; + color: var(--text-tertiary); +} +.excerpt-page { + display: inline-block; + margin-right: 6px; + padding: 1px 7px; + font-size: 10.5px; + font-weight: 600; + letter-spacing: 0.02em; + background: color-mix(in srgb, var(--primary) 12%, transparent); + color: var(--primary); + border-radius: 999px; + font-style: normal; + vertical-align: 1px; +} + +.back-link { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 12px 6px 10px; border-radius: 8px; + font-size: 13px; font-weight: 500; color: var(--text-tertiary); + text-decoration: none; border: 0.5px solid var(--border-default); + background: var(--bg-primary); flex-shrink: 0; + transition: color .15s, border-color .15s, background .15s; +} +.back-link:hover { + color: var(--primary); border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); +} +.back-link svg { opacity: 0.8; } +@media (max-width: 640px) { + .back-link span { display: none; } + .back-link { padding: 6px; } +} diff --git a/contract-check/static/index.html b/contract-check/static/index.html new file mode 100644 index 0000000..76cc5c5 --- /dev/null +++ b/contract-check/static/index.html @@ -0,0 +1,148 @@ + + + + + + Kontrola smluvních podmínek | Colsys AI + + + + + + + + + +
+
+ + C + Colsys AI + + Kontrola smluvních podmínek + + + Zpět na portál + +
+
+ +
+ + +
+
+

Kontrola smluvních podmínek

+

+ Nahrajte PDF smlouvu, vyberte body ke kontrole a spusťte analýzu. + Výsledkem je shrnutí s odkazy na strany a PDF se zvýrazněnými pasážemi. +

+
+ + +
+ + + +

Přetáhněte PDF smlouvu sem

+

nebo

+ +

Podporovaný formát: .pdf

+ +
+ + + + + +
+
+

Co kontrolovat

+
+ + +
+
+
+
+ + +
+
+
+ + + + + + + +
+ + + + diff --git a/contract-check/static/styles.css b/contract-check/static/styles.css new file mode 100644 index 0000000..9efda74 --- /dev/null +++ b/contract-check/static/styles.css @@ -0,0 +1,461 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f2f4f7; + --text-primary: #101828; + --text-secondary: #354052; + --text-tertiary: #676f83; + --text-quaternary: #98a2b2; + --border-default: rgb(16 24 40 / 0.08); + --border-strong: #d0d5dc; + --border-subtle: rgb(16 24 40 / 0.04); + --card: #ffffff; + --primary: #155aef; + --primary-hover: #004aeb; + --accent-indigo: #444ce7; + --shadow-card: 0 1px 2px rgb(16 24 40 / 0.05); + --radius-md: 8px; + --radius-lg: 12px; +} + +/* Dark theme — applies when (a) user OS prefers dark and no .light override, + or (b) :root has explicit .dark class (set by portal_theme cookie). */ +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; + } +} +:root.dark { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; +} + +body { + font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 100vh; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Header ─────────────────────────────────────── */ +.header { + position: sticky; + top: 0; + z-index: 30; + border-bottom: 0.5px solid var(--border-default); + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + backdrop-filter: blur(8px); +} +.header-inner { + max-width: 1280px; /* match portal max-w-7xl so brand doesn't shift */ + margin: 0 auto; + height: 56px; /* portal uses h-14 */ + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; /* portal px-4 */ + gap: 16px; +} +@media (min-width: 640px) { + .header-inner { padding: 0 32px; } /* portal sm:px-8 */ +} +.brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} +.brand-icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%); + box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 12px; + letter-spacing: -0.04em; + color: white; +} +.brand-name { + font-size: 14px; /* tailwind text-sm */ + font-weight: 600; + letter-spacing: -0.025em; /* tailwind tracking-tight */ + color: var(--text-primary); +} +.brand-ai { color: var(--primary); } +.header-crumb { + font-size: 13px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Main ────────────────────────────────────────── */ +.main { + max-width: 900px; + margin: 0 auto; + padding: 40px 24px 80px; + display: flex; + flex-direction: column; + gap: 32px; +} +@media (max-width: 640px) { + .header-inner { padding: 0 16px; } + .main { padding: 24px 16px 60px; } +} + +/* ── Section intro ───────────────────────────────── */ +.section-intro { margin-bottom: 24px; } +.section-title { + font-size: 22px; + font-weight: 600; + letter-spacing: -0.02em; + margin-bottom: 8px; +} +.section-desc { + color: var(--text-tertiary); + max-width: 580px; + font-size: 14px; + line-height: 1.6; +} + +/* ── Examples panel ──────────────────────────────── */ +.examples-panel { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--card); + box-shadow: var(--shadow-card); + padding: 16px; + margin-bottom: 16px; +} +.examples-header { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 12px; +} +.examples-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} +.examples-subtitle { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; +} +.examples-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 28px; + margin-bottom: 10px; +} +.examples-list:empty::before { + content: "Zatím žádné vzory — přidejte alespoň jeden níže"; + font-size: 12px; + color: var(--text-quaternary); + font-style: italic; + padding: 6px 0; +} +.example-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent); + color: var(--text-primary); + padding: 4px 4px 4px 10px; + border-radius: 999px; + font-size: 12px; + font-family: ui-monospace, monospace; +} +.example-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-tertiary); + padding: 0; + transition: background 0.1s, color 0.1s; +} +.example-chip-remove:hover { + background: color-mix(in srgb, var(--primary) 18%, transparent); + color: var(--text-primary); +} +.example-chip-remove svg { + width: 12px; + height: 12px; +} +.examples-input-row { + display: flex; + gap: 8px; +} +.example-input { + flex: 1; + font-family: ui-monospace, monospace; + font-size: 13px; + padding: 8px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-primary); + min-width: 0; +} +.example-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} +.example-add-btn { + flex-shrink: 0; +} + +/* ── Drop zone ───────────────────────────────────── */ +.drop-zone { + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--card); + padding: 56px 32px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + box-shadow: var(--shadow-card); +} +.drop-zone:hover, .drop-zone.drag-over { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 4%, var(--card)); +} +.drop-icon { + width: 44px; + height: 44px; + color: var(--text-quaternary); + margin: 0 auto 18px; + display: block; + transition: color 0.15s; +} +.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon { + color: var(--primary); +} +.drop-text { + font-size: 15px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} +.drop-or { + font-size: 13px; + color: var(--text-quaternary); + margin-bottom: 14px; +} +.drop-formats { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 14px; +} + +/* ── Buttons ─────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.15s, box-shadow 0.15s; + line-height: 1; +} +.btn-primary { + background: var(--primary); + color: #fff; + box-shadow: 0 1px 2px rgb(21 90 239 / 0.25); +} +.btn-primary:hover { background: var(--primary-hover); } +.btn-primary:disabled { opacity: 0.5; cursor: default; } +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-strong); +} +.btn-secondary:hover { background: var(--border-strong); } +.btn-icon { width: 15px; height: 15px; flex-shrink: 0; } + +/* ── Processing card ─────────────────────────────── */ +.processing-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 56px 32px; + text-align: center; + box-shadow: var(--shadow-card); +} +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border-strong); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin: 0 auto 22px; +} +@keyframes spin { to { transform: rotate(360deg); } } +.processing-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 20px; + color: var(--text-primary); +} +.steps-list { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; + margin: 0 auto; + text-align: left; +} +.step-item { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text-tertiary); + padding: 8px 12px; + border-radius: var(--radius-md); + background: var(--bg-tertiary); +} +.step-item.active { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); +} +.step-item.done { color: #17b26a; } +.step-item.error { color: #d92d20; background: #fef3f2; } +.step-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +/* ── Results ─────────────────────────────────────── */ +.results-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.results-title { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.015em; + margin-bottom: 4px; +} +.results-meta { + font-size: 13px; + color: var(--text-tertiary); +} +.results-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ── Table ───────────────────────────────────────── */ +.table-wrap { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-card); + background: var(--card); + overflow-x: auto; +} +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + min-width: 500px; +} +thead { + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-default); +} +th { + padding: 10px 14px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + white-space: nowrap; +} +td { + padding: 8px 14px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-primary); + vertical-align: middle; +} +tr:last-child td { border-bottom: none; } +tr:nth-child(even) td { background: var(--bg-secondary); } +td[contenteditable]:focus { + outline: none; + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); + box-shadow: inset 0 0 0 1.5px var(--primary); + border-radius: 3px; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 11px; + font-weight: 500; +} +.badge-rule { background: #d1e0ff; color: #004aeb; } +.badge-llm { background: #d1fae5; color: #065f46; } +@media (prefers-color-scheme: dark) { + .badge-rule { background: #1e3a8a; color: #93c5fd; } + .badge-llm { background: #064e3b; color: #6ee7b7; } +} + +.table-hint { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 8px; +} + +.hidden { display: none !important; } diff --git a/dify-workflows/shrnuti-dokumentu.yml b/dify-workflows/shrnuti-dokumentu.yml new file mode 100644 index 0000000..80928b3 --- /dev/null +++ b/dify-workflows/shrnuti-dokumentu.yml @@ -0,0 +1,170 @@ +app: + description: 'Vlož text dokumentu, získáš stručné shrnutí v 5 bodech.' + icon: 📄 + icon_background: '#E0F2FE' + mode: workflow + name: Shrnutí dokumentu + use_icon_as_answer_icon: false +kind: app +version: 0.4.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + enabled: false + opening_statement: '' + retriever_resource: + enabled: false + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: llm + id: start-to-llm + source: 'start_node' + sourceHandle: source + target: 'llm_node' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: llm-to-end + source: 'llm_node' + sourceHandle: source + target: 'end_node' + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + desc: '' + selected: false + title: Vstup + type: start + variables: + - label: Text dokumentu + max_length: 50000 + options: [] + required: true + type: paragraph + variable: dokument + - label: Styl shrnutí + max_length: 100 + options: + - Stručné body (do 5) + - Detailní souhrn (1 odstavec) + - TL;DR (1 věta) + required: true + type: select + variable: styl + height: 130 + id: 'start_node' + position: + x: 30 + y: 200 + positionAbsolute: + x: 30 + y: 200 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 244 + - data: + context: + enabled: false + variable_selector: [] + desc: '' + model: + completion_params: + temperature: 0.3 + mode: chat + name: gpt-5-mini + provider: langgenius/openai_api_compatible/openai_api_compatible + prompt_template: + - id: sys-prompt + role: system + text: |- + Jsi asistent, který vytváří stručná česká shrnutí. + + Pravidla: + - Odpovídej VÝHRADNĚ česky. + - Drž se faktů z poskytnutého textu, nevymýšlej. + - Zachovej hlavní tezi, klíčová čísla, jména a termíny. + - Pokud je text příliš krátký na shrnutí, řekni to. + + Styl výstupu zvolí uživatel — řiď se přesně tím, co vybral. + - id: user-prompt + role: user + text: |- + Styl: {{#start_node.styl#}} + + Text k shrnutí: + --- + {{#start_node.dokument#}} + --- + selected: false + title: Shrnutí (LLM) + type: llm + variables: [] + vision: + enabled: false + height: 130 + id: 'llm_node' + position: + x: 350 + y: 200 + positionAbsolute: + x: 350 + y: 200 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 244 + - data: + desc: '' + outputs: + - value_selector: + - 'llm_node' + - text + value_type: string + variable: shrnuti + selected: false + title: Výstup + type: end + height: 90 + id: 'end_node' + position: + x: 670 + y: 200 + positionAbsolute: + x: 670 + y: 200 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 244 + viewport: + x: 0 + y: 0 + zoom: 0.8 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cb17324 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,60 @@ +services: + landing: + build: + context: ./landing + dockerfile: Dockerfile + container_name: ai-portal-landing + restart: unless-stopped + ports: + - "127.0.0.1:3010:3010" + - "100.95.104.123:3010:3010" # Tailscale (t6) + environment: + AUTH_SECRET: ${AUTH_SECRET:?set AUTH_SECRET in .env} + AUTH_TRUST_HOST: "true" + NEXTAUTH_URL: ${NEXTAUTH_URL:-https://ai.klas.chat} + DEV_PORTAL_PASSWORD: ${DEV_PORTAL_PASSWORD:-} + AUTH_MICROSOFT_ENTRA_ID_ID: ${AUTH_MICROSOFT_ENTRA_ID_ID:-} + AUTH_MICROSOFT_ENTRA_ID_SECRET: ${AUTH_MICROSOFT_ENTRA_ID_SECRET:-} + AUTH_MICROSOFT_ENTRA_ID_ISSUER: ${AUTH_MICROSOFT_ENTRA_ID_ISSUER:-} + networks: + - localai + + # Langfuse observability stack (optional for v1 — uncomment when ready). + # Reference: https://langfuse.com/self-hosting/docker-compose + # + # langfuse-db: + # image: postgres:16 + # container_name: ai-portal-langfuse-db + # restart: unless-stopped + # environment: + # POSTGRES_USER: langfuse + # POSTGRES_PASSWORD: ${LANGFUSE_DB_PASSWORD} + # POSTGRES_DB: langfuse + # volumes: + # - langfuse-db:/var/lib/postgresql/data + # networks: + # - localai + # + # langfuse: + # image: langfuse/langfuse:2 + # container_name: ai-portal-langfuse + # restart: unless-stopped + # depends_on: + # - langfuse-db + # ports: + # - "127.0.0.1:3011:3000" + # environment: + # DATABASE_URL: postgresql://langfuse:${LANGFUSE_DB_PASSWORD}@langfuse-db:5432/langfuse + # NEXTAUTH_URL: https://langfuse.klas.chat + # NEXTAUTH_SECRET: ${LANGFUSE_NEXTAUTH_SECRET} + # SALT: ${LANGFUSE_SALT} + # TELEMETRY_ENABLED: "false" + # networks: + # - localai + +# volumes: +# langfuse-db: + +networks: + localai: + external: true diff --git a/dwg-counting/.env.example b/dwg-counting/.env.example new file mode 100644 index 0000000..1bdec6f --- /dev/null +++ b/dwg-counting/.env.example @@ -0,0 +1,3 @@ +LITELLM_BASE_URL=http://host.docker.internal:4000/v1 +LITELLM_API_KEY=sk-... +LLM_MODEL=anthropic/claude-sonnet-4-20250514 diff --git a/dwg-counting/Dockerfile b/dwg-counting/Dockerfile new file mode 100644 index 0000000..c19b294 --- /dev/null +++ b/dwg-counting/Dockerfile @@ -0,0 +1,47 @@ +# Stage 1: compile LibreDWG (provides dwgread for DWG → DXF conversion) +FROM debian:bookworm-slim AS libredwg-builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + xz-utils \ + build-essential \ + autoconf \ + automake \ + libtool \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +COPY libredwg-0.12.5.tar.xz . +RUN tar xf libredwg-0.12.5.tar.xz \ + && cd libredwg-0.12.5 \ + && ./configure --prefix=/opt/libredwg --disable-shared --disable-bindings \ + && make -j"$(nproc)" \ + && make install \ + && cd .. \ + && rm -rf libredwg-0.12.5 libredwg-0.12.5.tar.xz + +# Stage 2: runtime +FROM python:3.12-slim + +COPY --from=libredwg-builder /opt/libredwg/bin/dwgread /usr/local/bin/dwgread + +# poppler-utils for pdf2image; cairo + pango for cairosvg +RUN apt-get update && apt-get install -y --no-install-recommends \ + poppler-utils \ + libgl1 \ + libcairo2 \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /tmp/dwg-counting + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/dwg-counting/counting.py b/dwg-counting/counting.py new file mode 100644 index 0000000..a742ce9 --- /dev/null +++ b/dwg-counting/counting.py @@ -0,0 +1,286 @@ +"""Shape-based symbol counting via contour matching. + +Why not cv2.matchTemplate: CAD symbols are usually thin-line drawings on a +white background (~10-20% ink). Normalized cross-correlation gives spuriously +high scores for any sparse-ink region (e.g. wall edges), producing false +positives everywhere. + +Approach used here: + 1. Binarize template + drawing to "ink" maps. + 2. Find external contours in both. + 3. Use the template's main contour as a shape reference. + 4. For every contour in the drawing, compare via cv2.matchShapes (Hu moments + — invariant to scale, rotation, translation). + 5. Filter by area ratio (similar size to template, allowing ±factor). + 6. Keep contours below a shape-distance threshold. + +This is the classic approach to CAD symbol takeoff. +""" +import logging +from pathlib import Path +from typing import Iterable + +import cv2 +import numpy as np + +logger = logging.getLogger(__name__) + +# Template matching: higher = stricter. 0.65 is permissive, 0.85 strict. +DEFAULT_THRESHOLD = 0.7 + + +def _prep(img_path: Path) -> np.ndarray: + """Binarize to 'ink vs not-ink'. + + No dilation — for contour-based matching we need lines to stay narrow + so distinct symbols don't merge into one mega-contour through CAD's + dense linework. + """ + arr = cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE) + if arr is None: + raise RuntimeError(f"Could not load {img_path}") + _, ink = cv2.threshold(arr, 245, 255, cv2.THRESH_BINARY_INV) + return ink + + +def _crop_to_content(template: np.ndarray, bg_threshold: int = 240) -> np.ndarray: + """Crop a template to its non-background bounding box. + + After _prep the template is BINARY (ink=255, bg=0). So 'content' is + everything with value > 0. The bg_threshold param is kept for the + grayscale case for backward compat. + """ + if template.max() <= 1 or template.min() == 0 and template.max() == 255: + # Binary map: any non-zero pixel is ink + mask = template > 0 + else: + mask = template < bg_threshold + if not mask.any(): + return template + ys, xs = np.where(mask) + y0, y1 = ys.min(), ys.max() + 1 + x0, x1 = xs.min(), xs.max() + 1 + pad = 2 + y0 = max(0, y0 - pad) + x0 = max(0, x0 - pad) + y1 = min(template.shape[0], y1 + pad) + x1 = min(template.shape[1], x1 + pad) + return template[y0:y1, x0:x1] + + +def _nms(boxes: list[tuple], scores: list[float], overlap_thresh: float = 0.3) -> list[int]: + """Non-max suppression. Returns indices of kept boxes.""" + if not boxes: + return [] + boxes_arr = np.array(boxes, dtype=np.float32) + x1 = boxes_arr[:, 0] + y1 = boxes_arr[:, 1] + x2 = boxes_arr[:, 2] + y2 = boxes_arr[:, 3] + areas = (x2 - x1) * (y2 - y1) + order = np.argsort(scores)[::-1] + keep = [] + while order.size > 0: + i = order[0] + keep.append(int(i)) + xx1 = np.maximum(x1[i], x1[order[1:]]) + yy1 = np.maximum(y1[i], y1[order[1:]]) + xx2 = np.minimum(x2[i], x2[order[1:]]) + yy2 = np.minimum(y2[i], y2[order[1:]]) + w = np.maximum(0.0, xx2 - xx1) + h = np.maximum(0.0, yy2 - yy1) + inter = w * h + union = areas[i] + areas[order[1:]] - inter + iou = inter / np.maximum(union, 1e-6) + order = order[1:][iou <= overlap_thresh] + return keep + + +MAX_DRAWING_PX = 9000 # effectively no downscale for typical A1/A0 + + +def debug_template(template_path: Path, drawing_path: Path) -> dict: + """Return diagnostics for tuning a symbol template.""" + template = _prep(template_path) + template_cropped = _crop_to_content(template) + drawing = _prep(drawing_path) + info = { + "template_size": list(template.shape), + "template_cropped_size": list(template_cropped.shape), + "template_ink_pixels": int((template_cropped > 0).sum() + if template_cropped.max() == 255 and template_cropped.min() == 0 + else (template_cropped < 240).sum()), + "template_total_pixels": int(template_cropped.size), + "drawing_size": list(drawing.shape), + } + if min(template_cropped.shape) < 8: + info["error"] = "template too small after content crop" + return info + # Scale sweep — finds the size at which template matches best + scale_scan = [] + best_overall = -1.0 + for scale in (0.15, 0.25, 0.35, 0.5, 0.7, 0.85, 1.0, 1.2, 1.5, 2.0): + nw = max(8, int(template_cropped.shape[1] * scale)) + nh = max(8, int(template_cropped.shape[0] * scale)) + if nh > drawing.shape[0] or nw > drawing.shape[1]: + continue + tmpl = cv2.resize(template_cropped, (nw, nh), interpolation=cv2.INTER_AREA) + res = cv2.matchTemplate(drawing, tmpl, cv2.TM_CCOEFF_NORMED) + m = float(res.max()) + scale_scan.append({ + "scale": scale, + "template_px": [nh, nw], + "max_score": round(m, 3), + "count_at_0.7": int((res >= 0.7).sum()), + "count_at_0.6": int((res >= 0.6).sum()), + }) + if m > best_overall: + best_overall = m + info["max_score"] = best_overall + info["scale_scan"] = scale_scan + # Threshold counts at scale=1.0 for reference + result = cv2.matchTemplate(drawing, template_cropped, cv2.TM_CCOEFF_NORMED) + info["matches_at_threshold"] = { + "0.60": int((result >= 0.60).sum()), + "0.70": int((result >= 0.70).sum()), + "0.75": int((result >= 0.75).sum()), + "0.80": int((result >= 0.80).sum()), + "0.85": int((result >= 0.85).sum()), + "0.90": int((result >= 0.90).sum()), + } + return info + + +def _merge_template_contour(ink: np.ndarray) -> np.ndarray | None: + """Combine all strokes of a template into one contour via closing. + + Returns the biggest connected component. The template is small enough that + closing safely merges the line + arc of symbols like '-C' into one shape + without affecting matching against the drawing. + """ + closed = cv2.morphologyEx(ink, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8)) + contours, _ = cv2.findContours(closed, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) + if not contours: + return None + return max(contours, key=cv2.contourArea) + + +def count_template( + template_path: Path, + drawing_path: Path, + threshold: float = DEFAULT_THRESHOLD, + rotations: Iterable[int] = (0, 90, 180, 270), + scales: Iterable[float] = (0.6, 0.8, 1.0, 1.25, 1.5), + exclude_box: tuple | None = None, + area_tolerance: float = 200.0, + mirror: bool = True, +) -> dict: + """Multi-scale, multi-rotation template matching (TM_SQDIFF_NORMED). + + With mirror=True the template is also tested flipped horizontally — at + each rotation — so we catch mirrored instances (e.g. a socket facing + left vs facing right). + """ + template = _prep(template_path) + template = _crop_to_content(template) + drawing = _prep(drawing_path) + + coord_scale = 1.0 + if max(drawing.shape) > MAX_DRAWING_PX: + coord_scale = max(drawing.shape) / MAX_DRAWING_PX + new_w = int(drawing.shape[1] / coord_scale) + new_h = int(drawing.shape[0] / coord_scale) + drawing = cv2.resize(drawing, (new_w, new_h), interpolation=cv2.INTER_AREA) + + if exclude_box is not None: + ex_x, ex_y, ex_w, ex_h = exclude_box + ex_x = int(ex_x / coord_scale) + ex_y = int(ex_y / coord_scale) + ex_w = int(ex_w / coord_scale) + ex_h = int(ex_h / coord_scale) + h, w = drawing.shape + ex_x = max(0, min(w, ex_x)) + ex_y = max(0, min(h, ex_y)) + ex_x2 = max(0, min(w, ex_x + ex_w)) + ex_y2 = max(0, min(h, ex_y + ex_h)) + drawing[ex_y:ex_y2, ex_x:ex_x2] = 0 + logger.info("Masked legend region (%d,%d,%d,%d)", ex_x, ex_y, ex_w, ex_h) + + if min(template.shape) < 8 or int((template > 0).sum()) < 5: + logger.warning("Template too small / empty after preprocessing") + return {"count": 0, "matches": [], "threshold_used": threshold} + + all_boxes: list[tuple] = [] + all_scores: list[float] = [] + + # Build the variants we'll search with: rotations × (original, mirrored) + variants = [(template, False)] + if mirror: + variants.append((cv2.flip(template, 1), True)) + + def _rotate(img, angle_deg): + if angle_deg == 0: + return img + if angle_deg in (90, 180, 270): + return np.rot90(img, k=angle_deg // 90) + # Arbitrary angle — rotate with bbox expansion + white fill + h, w = img.shape[:2] + center = (w / 2, h / 2) + M = cv2.getRotationMatrix2D(center, angle_deg, 1.0) + cos = abs(M[0, 0]); sin = abs(M[0, 1]) + new_w = int(h * sin + w * cos) + new_h = int(h * cos + w * sin) + M[0, 2] += new_w / 2 - center[0] + M[1, 2] += new_h / 2 - center[1] + return cv2.warpAffine(img, M, (new_w, new_h), + flags=cv2.INTER_NEAREST, borderValue=0) + + # Build the full job list (one entry per rot×scale×mirror) + jobs_list = [] + for variant, _ in variants: + for rot in rotations: + base = _rotate(variant, rot) + for scale in scales: + new_w = max(8, int(base.shape[1] * scale)) + new_h = max(8, int(base.shape[0] * scale)) + if new_h > drawing.shape[0] or new_w > drawing.shape[1]: + continue + tmpl = cv2.resize(base, (new_w, new_h), + interpolation=cv2.INTER_AREA) + jobs_list.append((tmpl, new_w, new_h)) + + def _run(args): + tmpl, new_w, new_h = args + sq = cv2.matchTemplate(drawing, tmpl, cv2.TM_SQDIFF_NORMED) + cc = cv2.matchTemplate(drawing, tmpl, cv2.TM_CCOEFF_NORMED) + sim = np.maximum(1.0 - sq, cc) + ys, xs = np.where(sim >= threshold) + out = [] + for y, x in zip(ys, xs): + out.append((float(x), float(y), float(x + new_w), + float(y + new_h), float(sim[y, x]))) + return out + + # Threading: cv2.matchTemplate releases the GIL, so threads give real speedup + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as ex: + for result in ex.map(_run, jobs_list): + for x0, y0, x1, y1, score in result: + all_boxes.append((x0, y0, x1, y1)) + all_scores.append(score) + + if not all_boxes: + return {"count": 0, "matches": [], "threshold_used": threshold} + + keep = _nms(all_boxes, all_scores, overlap_thresh=0.3) + matches = [] + for i in keep: + x0, y0, x1, y1 = all_boxes[i] + matches.append({ + "x": int(x0 * coord_scale), + "y": int(y0 * coord_scale), + "w": int((x1 - x0) * coord_scale), + "h": int((y1 - y0) * coord_scale), + "score": round(all_scores[i], 3), + }) + return {"count": len(matches), "matches": matches, "threshold_used": threshold} diff --git a/dwg-counting/docker-compose.yml b/dwg-counting/docker-compose.yml new file mode 100644 index 0000000..985fa3d --- /dev/null +++ b/dwg-counting/docker-compose.yml @@ -0,0 +1,24 @@ +services: + dwg-counting: + build: . + container_name: dwg-counting + restart: unless-stopped + ports: + - "127.0.0.1:3026:8000" + environment: + LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://host.docker.internal:4000/v1} + LITELLM_API_KEY: ${LITELLM_API_KEY:-} + LLM_MODEL: ${LLM_MODEL:-anthropic/claude-sonnet-4-20250514} + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - localai + volumes: + - dwg-counting-data:/tmp/dwg-counting + +volumes: + dwg-counting-data: + +networks: + localai: + external: true diff --git a/dwg-counting/excel_export.py b/dwg-counting/excel_export.py new file mode 100644 index 0000000..275d331 --- /dev/null +++ b/dwg-counting/excel_export.py @@ -0,0 +1,41 @@ +"""Export symbol count results to XLSX.""" +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment + + +def export_to_excel(results: list[dict], filename: str, out_path: str) -> None: + """results = [{id, description, count, confidence, notes}, ...]""" + wb = Workbook() + ws = wb.active + ws.title = "Symboly" + + headers = ["Symbol", "Popis", "Počet", "Spolehlivost", "Poznámka"] + ws.append(headers) + for cell in ws[1]: + cell.font = Font(bold=True, color="FFFFFF") + cell.fill = PatternFill("solid", fgColor="2563EB") + cell.alignment = Alignment(horizontal="center") + + for r in results: + ws.append([ + r.get("id", ""), + r.get("description", ""), + r.get("count", 0), + r.get("confidence", ""), + r.get("notes", ""), + ]) + + # Auto-width + widths = [16, 60, 10, 14, 40] + for i, w in enumerate(widths, 1): + ws.column_dimensions[chr(64 + i)].width = w + + # Total row + total = sum(int(r.get("count", 0) or 0) for r in results) + ws.append([]) + last = ws.max_row + 1 + ws.append(["", f"Celkem ({filename})", total, "", ""]) + ws[f"B{last+1}"].font = Font(bold=True) + ws[f"C{last+1}"].font = Font(bold=True) + + wb.save(out_path) diff --git a/dwg-counting/libredwg-0.12.5.tar.xz b/dwg-counting/libredwg-0.12.5.tar.xz new file mode 100644 index 0000000..89f0806 Binary files /dev/null and b/dwg-counting/libredwg-0.12.5.tar.xz differ diff --git a/dwg-counting/main.py b/dwg-counting/main.py new file mode 100644 index 0000000..042d445 --- /dev/null +++ b/dwg-counting/main.py @@ -0,0 +1,440 @@ +"""FastAPI app: upload DWG/DXF/PDF → vision-detect legend → count selected symbols → Excel.""" +import asyncio +import logging +import os +import uuid +from pathlib import Path + +from fastapi import FastAPI, File, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from counting import count_template, debug_template +from excel_export import export_to_excel +from pdf_export import render_annotated_pdf +from renderer import crop_region, render +from vision import detect_legend + +DEFAULT_FLOOR_INDEX = 0 # MVP: process the first detected floor + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="DWG Symbol Counter") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/dwg-counting")) +WORK_DIR.mkdir(parents=True, exist_ok=True) + +jobs: dict[str, dict] = {} +# Persistent action log so the operator can replay what the user did. +ACTION_LOG = WORK_DIR / "action.log" + + +def _log_action(event: str, **fields): + import datetime, json as _json + line = _json.dumps({ + "ts": datetime.datetime.now().isoformat(timespec="seconds"), + "event": event, + **fields, + }, ensure_ascii=False) + try: + with open(ACTION_LOG, "a") as f: + f.write(line + "\n") + except Exception: + pass + + +@app.get("/") +async def root(): + return FileResponse("static/index.html") + + +@app.post("/api/upload") +async def upload(file: UploadFile = File(...), auto_detect: bool = False): + suffix = Path(file.filename or "").suffix.lower() + if suffix != ".pdf": + raise HTTPException(400, "Podporovaný formát: .pdf") + + job_id = str(uuid.uuid4()) + job_dir = WORK_DIR / job_id + job_dir.mkdir() + + input_path = job_dir / f"input{suffix}" + input_path.write_bytes(await file.read()) + logger.info("Job %s: %s (%d bytes)", job_id, file.filename, input_path.stat().st_size) + _log_action("upload", job_id=job_id, filename=file.filename, + size=input_path.stat().st_size, auto_detect=auto_detect) + + try: + rendered = await asyncio.to_thread(render, input_path, job_dir) + floors = rendered["floors"] + if not floors: + raise RuntimeError("Z výkresu se nepodařilo nic vyrenderovat") + floor = floors[DEFAULT_FLOOR_INDEX] + png_path = job_dir / floor["png"] + legend_norm = floor.get("legend_norm_bbox") + legend_pixel_box = None + symbols = [] + detect_path = png_path + + if legend_norm: + from PIL import Image as _Img + full_img = _Img.open(png_path) + W, H = full_img.size + nx0, ny0, nx1, ny1 = legend_norm + # Expand box generously around the LEGENDA text + cx = (nx0 + nx1) / 2 + cy = (ny0 + ny1) / 2 + # Legend rows extend DOWNWARD from the LEGENDA header text. + # Crop a narrow column starting just above the header. + half_w = 0.10 + top_pad = 0.02 + below = 0.30 + px0 = max(0, int((cx - half_w) * W)) + px1 = min(W, int((cx + half_w) * W)) + py0 = max(0, int((cy - top_pad) * H)) + py1 = min(H, int((cy + below) * H)) + legend_crop = job_dir / "legend_area.png" + crop_img = full_img.crop((px0, py0, px1, py1)) + crop_img.save(legend_crop, "PNG") + detect_path = legend_crop + legend_pixel_box = (px0, py0, px1 - px0, py1 - py0) + logger.info("Legend area %dx%d ready", px1 - px0, py1 - py0) + + if auto_detect: + symbols = await detect_legend(detect_path) + + # Symbol bboxes returned by vision are normalized to the image vision saw + # (detect_path), which may be the cropped legend region or the full page. + for s in symbols: + bbox = s.get("bbox") or {} + if all(k in bbox for k in ("x", "y", "w", "h")): + crop_path = job_dir / f"sym_{s['id']}.png" + try: + crop_region(detect_path, bbox, crop_path, pad=1.5, min_px=120) + s["crop_file"] = crop_path.name + except Exception as exc: + logger.warning("Crop failed for %s: %s", s.get("id"), exc) + + jobs[job_id] = { + "filename": file.filename, + "png_path": str(png_path), + "job_dir": str(job_dir), + "floors": floors, + "symbols": symbols, + "results": [], + "legend_pixel_box": legend_pixel_box, + "next_user_sym_id": 1, + } + return {"job_id": job_id, "symbols": symbols, "floor_count": len(floors), + "auto_detect": auto_detect} + + except Exception as exc: + logger.exception("Job %s failed: %s", job_id, exc) + raise HTTPException(500, str(exc)) + + +@app.get("/api/preview/{job_id}") +async def preview(job_id: str): + if job_id not in jobs: + raise HTTPException(404, "Not found") + return FileResponse(jobs[job_id]["png_path"], media_type="image/png") + + +@app.get("/api/symbol/{job_id}/{sym_id}") +async def symbol_crop(job_id: str, sym_id: str): + if job_id not in jobs: + raise HTTPException(404, "Not found") + job_dir = Path(jobs[job_id]["job_dir"]) + crop_path = job_dir / f"sym_{sym_id}.png" + if not crop_path.exists(): + raise HTTPException(404, "Crop not available") + return FileResponse(crop_path, media_type="image/png") + + +@app.get("/api/legend/{job_id}") +async def legend_image(job_id: str): + """Return the cropped legend area image (what vision saw).""" + if job_id not in jobs: + raise HTTPException(404, "Not found") + legend_path = Path(jobs[job_id]["job_dir"]) / "legend_area.png" + if not legend_path.exists(): + # Fall back to full page if no legend crop + legend_path = Path(jobs[job_id]["png_path"]) + return FileResponse(legend_path, media_type="image/png") + + +class RecropRequest(BaseModel): + bbox: dict # {x, y, w, h} normalized 0-1 relative to the legend image + + +class CreateSymbolRequest(BaseModel): + bbox: dict # normalized 0-1 relative to the FULL drawing image + description: str + source: str = "drawing" # "drawing" or "legend" + + +@app.post("/api/symbols/{job_id}") +async def create_user_symbol(job_id: str, req: CreateSymbolRequest): + """User drew a rectangle on the drawing → create a symbol from that crop.""" + if job_id not in jobs: + raise HTTPException(404, "Not found") + job = jobs[job_id] + job_dir = Path(job["job_dir"]) + # Pick source image + if req.source == "legend": + src = job_dir / "legend_area.png" + if not src.exists(): + src = Path(job["png_path"]) + else: + src = Path(job["png_path"]) + + sym_id = f"user_{job['next_user_sym_id']}" + job["next_user_sym_id"] += 1 + crop_path = job_dir / f"sym_{sym_id}.png" + # User's rectangle is exact — no padding, no min size enforcement. + crop_region(src, req.bbox, crop_path, pad=0.0, min_px=0) + sym = { + "id": sym_id, + "description": req.description or sym_id, + "bbox": req.bbox, + "crop_file": crop_path.name, + "user_defined": True, + } + job["symbols"].append(sym) + _log_action("create_symbol", job_id=job_id, sym_id=sym_id, + description=req.description, bbox=req.bbox, source=req.source) + return sym + + +@app.post("/api/symbols/{job_id}/upload") +async def upload_symbol_image( + job_id: str, + file: UploadFile = File(...), + description: str = "", +): + """Accept a pre-cropped symbol image as a template, bypassing the + rectangle-drawing UI. Useful when the user has a clean PNG from elsewhere. + """ + if job_id not in jobs: + raise HTTPException(404, "Not found") + job = jobs[job_id] + job_dir = Path(job["job_dir"]) + sym_id = f"user_{job['next_user_sym_id']}" + job["next_user_sym_id"] += 1 + crop_path = job_dir / f"sym_{sym_id}.png" + raw = await file.read() + crop_path.write_bytes(raw) + # Normalize: ensure RGB on white background (drop alpha so processing + # doesn't see transparency as "not white") + from PIL import Image as _Img + img = _Img.open(crop_path) + if img.mode in ("RGBA", "LA"): + bg = _Img.new("RGB", img.size, (255, 255, 255)) + bg.paste(img, mask=img.split()[-1]) + bg.save(crop_path, "PNG") + sym = { + "id": sym_id, + "description": (description or file.filename or sym_id).strip(), + "crop_file": crop_path.name, + "user_defined": True, + "uploaded": True, + } + job["symbols"].append(sym) + _log_action("upload_symbol", job_id=job_id, sym_id=sym_id, + description=sym["description"], filename=file.filename, + size=len(raw)) + return sym + + +@app.delete("/api/symbols/{job_id}/{sym_id}") +async def delete_symbol(job_id: str, sym_id: str): + if job_id not in jobs: + raise HTTPException(404, "Not found") + job = jobs[job_id] + job["symbols"] = [s for s in job["symbols"] if s["id"] != sym_id] + crop = Path(job["job_dir"]) / f"sym_{sym_id}.png" + if crop.exists(): + crop.unlink() + return {"ok": True} + + +@app.post("/api/auto-detect/{job_id}") +async def trigger_auto_detect(job_id: str): + """Run the vision legend detection on demand (optional shortcut).""" + if job_id not in jobs: + raise HTTPException(404, "Not found") + job = jobs[job_id] + job_dir = Path(job["job_dir"]) + legend_path = job_dir / "legend_area.png" + detect_path = legend_path if legend_path.exists() else Path(job["png_path"]) + found = await detect_legend(detect_path) + for s in found: + bbox = s.get("bbox") or {} + if all(k in bbox for k in ("x", "y", "w", "h")): + crop_path = job_dir / f"sym_{s['id']}.png" + try: + crop_region(detect_path, bbox, crop_path, pad=1.5, min_px=120) + s["crop_file"] = crop_path.name + except Exception as exc: + logger.warning("Crop failed for %s: %s", s.get("id"), exc) + # Replace vision-detected ones (keep user-defined) + job["symbols"] = [s for s in job["symbols"] if s.get("user_defined")] + found + return {"symbols": job["symbols"]} + + +@app.get("/api/drawing/{job_id}") +async def drawing_image(job_id: str): + """Return the rendered full drawing image (for the user to crop on).""" + if job_id not in jobs: + raise HTTPException(404, "Not found") + return FileResponse(jobs[job_id]["png_path"], media_type="image/png") + + +@app.get("/api/debug/{job_id}/{sym_id}") +async def debug_symbol(job_id: str, sym_id: str): + """Diagnostics about a symbol's template: size, ink, match scores.""" + if job_id not in jobs: + raise HTTPException(404, "Not found") + job = jobs[job_id] + crop = Path(job["job_dir"]) / f"sym_{sym_id}.png" + if not crop.exists(): + raise HTTPException(404, "Crop not available") + drawing = Path(job["png_path"]) + info = await asyncio.to_thread(debug_template, crop, drawing) + return info + + +@app.get("/api/debug-template/{job_id}/{sym_id}") +async def debug_template_image(job_id: str, sym_id: str): + """Return the *processed* template (what the matcher actually sees).""" + import cv2 + from counting import _prep, _crop_to_content + if job_id not in jobs: + raise HTTPException(404, "Not found") + job = jobs[job_id] + crop = Path(job["job_dir"]) / f"sym_{sym_id}.png" + if not crop.exists(): + raise HTTPException(404, "Crop not available") + tmpl = _prep(crop) + tmpl = _crop_to_content(tmpl) + out = Path(job["job_dir"]) / f"sym_{sym_id}_processed.png" + cv2.imwrite(str(out), tmpl) + return FileResponse(str(out), media_type="image/png") + + +@app.post("/api/symbol/{job_id}/{sym_id}/recrop") +async def recrop_symbol(job_id: str, sym_id: str, req: RecropRequest): + """Replace a symbol's crop. bbox is normalized 0-1 relative to the FULL + drawing image (which is what the frontend shows in the editor).""" + if job_id not in jobs: + raise HTTPException(404, "Not found") + job = jobs[job_id] + src = Path(job["png_path"]) + crop_path = Path(job["job_dir"]) / f"sym_{sym_id}.png" + crop_region(src, req.bbox, crop_path, pad=0.0, min_px=0) + return {"ok": True, "crop_file": crop_path.name} + + +class CountRequest(BaseModel): + symbol_ids: list[str] + threshold: float | None = None # Override default (0.7); lower = more matches + + +@app.post("/api/count/{job_id}") +async def count(job_id: str, req: CountRequest): + if job_id not in jobs: + raise HTTPException(404, "Not found") + job = jobs[job_id] + png = Path(job["png_path"]) + job_dir = Path(job["job_dir"]) + selected = [s for s in job["symbols"] if s["id"] in req.symbol_ids] + + # Determine the legend mask box (avoid matching the legend itself). + legend_box = job.get("legend_pixel_box") # set during upload if available + + thr = req.threshold if req.threshold is not None else None + + def _count_one(sym): + crop = job_dir / f"sym_{sym['id']}.png" + if not crop.exists(): + return {"id": sym["id"], "description": sym["description"], + "count": 0, "matches": [], "notes": "no crop"} + try: + kwargs = {"exclude_box": legend_box} + if thr is not None: + kwargs["threshold"] = thr + res = count_template(crop, png, **kwargs) + except Exception as exc: + logger.exception("Counting failed for %s", sym["id"]) + return {"id": sym["id"], "description": sym["description"], + "count": 0, "matches": [], "notes": f"error: {exc}"} + return { + "id": sym["id"], + "description": sym["description"], + "count": res["count"], + "matches": res["matches"], + "notes": "" if res["count"] else "žádné shody nenalezeny", + } + + # Serialize OpenCV calls — parallel matchTemplate on a 4000px drawing + # blows past 4GB peak memory and OOM-kills the container. + results = [] + for s in selected: + r = await asyncio.to_thread(_count_one, s) + results.append(r) + _log_action("count_one", job_id=job_id, sym_id=s["id"], + description=s.get("description"), count=r.get("count")) + job["results"] = list(results) + # Trim matches from API response (keep them server-side for PDF export) + response_results = [{k: v for k, v in r.items() if k != "matches"} | {"count": r["count"]} + for r in job["results"]] + return {"results": response_results} + + +@app.get("/api/export/{job_id}") +async def export(job_id: str): + if job_id not in jobs: + raise HTTPException(404, "Not found") + job = jobs[job_id] + if not job["results"]: + raise HTTPException(400, "Nejprve spočítejte symboly") + out_path = Path(job["job_dir"]) / "counts.xlsx" + export_to_excel(job["results"], job["filename"] or "drawing", str(out_path)) + stem = Path(job["filename"]).stem if job["filename"] else "drawing" + return FileResponse( + str(out_path), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + filename=f"symboly_{stem}.xlsx", + ) + + +@app.get("/api/export-pdf/{job_id}") +async def export_pdf(job_id: str): + if job_id not in jobs: + raise HTTPException(404, "Not found") + job = jobs[job_id] + if not job["results"]: + raise HTTPException(400, "Nejprve spočítejte symboly") + png_path = Path(job["png_path"]) + stem = Path(job["filename"]).stem if job["filename"] else "drawing" + out_path = Path(job["job_dir"]) / "annotated.pdf" + await asyncio.to_thread( + render_annotated_pdf, png_path, job["results"], out_path, stem, + ) + return FileResponse( + str(out_path), + media_type="application/pdf", + filename=f"vyznaceno_{stem}.pdf", + ) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/dwg-counting/pdf_export.py b/dwg-counting/pdf_export.py new file mode 100644 index 0000000..1e24b21 --- /dev/null +++ b/dwg-counting/pdf_export.py @@ -0,0 +1,93 @@ +"""Render annotated PDF with color-coded symbol match highlights.""" +import logging +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +logger = logging.getLogger(__name__) + +# Distinct visually-separable colors (RGB) for up to 12 symbol types +PALETTE = [ + (228, 26, 28), # red + (55, 126, 184), # blue + (77, 175, 74), # green + (152, 78, 163), # purple + (255, 127, 0), # orange + (0, 184, 184), # teal + (247, 129, 191), # pink + (153, 153, 0), # olive + (166, 86, 40), # brown + (102, 102, 102), # grey + (0, 0, 0), # black + (190, 81, 51), # rust +] + + +def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont: + for path in ( + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + ): + try: + return ImageFont.truetype(path, size) + except OSError: + continue + return ImageFont.load_default() + + +def render_annotated_pdf( + drawing_path: Path, + results: list[dict], + out_path: Path, + filename: str = "drawing", +) -> Path: + """Build a multi-page PDF: page 1 = legend, page 2 = annotated drawing. + + results: [{id, description, count, matches: [{x,y,w,h,score,rot}, ...]}, ...] + """ + base = Image.open(drawing_path).convert("RGB") + annotated = base.copy() + draw = ImageDraw.Draw(annotated) + + # Scale stroke width by image size so boxes are visible at any zoom + stroke = max(2, min(annotated.size) // 800) + label_font = _load_font(max(14, min(annotated.size) // 200)) + + for idx, r in enumerate(results): + color = PALETTE[idx % len(PALETTE)] + for m in r.get("matches", []): + x, y, w, h = m["x"], m["y"], m["w"], m["h"] + draw.rectangle([x, y, x + w, y + h], outline=color, width=stroke) + + # Legend page + legend_w = annotated.size[0] + row_h = max(36, legend_w // 30) + legend_h = row_h * (len(results) + 4) + legend = Image.new("RGB", (legend_w, legend_h), "white") + ldraw = ImageDraw.Draw(legend) + title_font = _load_font(max(28, legend_w // 50)) + ldraw.text((40, 30), f"Počítání symbolů — {filename}", font=title_font, fill="black") + ldraw.text( + (40, 30 + row_h), + f"Nalezeno {sum(r['count'] for r in results)} symbolů v {len(results)} kategoriích.", + font=label_font, fill="black", + ) + + y = 30 + 3 * row_h + swatch = row_h - 10 + for idx, r in enumerate(results): + color = PALETTE[idx % len(PALETTE)] + ldraw.rectangle([40, y, 40 + swatch, y + swatch], fill=color, outline="black", width=2) + text = f"{r.get('description', r['id'])} — {r['count']}×" + ldraw.text((40 + swatch + 16, y), text, font=label_font, fill="black") + y += row_h + + # Save as multi-page PDF + legend.save( + out_path, + "PDF", + resolution=150.0, + save_all=True, + append_images=[annotated], + ) + return out_path diff --git a/dwg-counting/renderer.py b/dwg-counting/renderer.py new file mode 100644 index 0000000..174fcc2 --- /dev/null +++ b/dwg-counting/renderer.py @@ -0,0 +1,351 @@ +"""Render DWG/DXF/PDF → PNG image(s) for vision model consumption. + +Strategy: multi-floor architectural drawings are split per detected legend. +Each floor renders to its own PNG at a resolution where individual symbols +remain distinguishable for the vision model. +""" +import logging +import subprocess +from pathlib import Path + +import io + +import ezdxf +from ezdxf.addons.drawing.config import ( + BackgroundPolicy, ColorPolicy, Configuration, +) +from ezdxf.addons.drawing import Frontend, RenderContext, layout +from ezdxf.addons.drawing.svg import SVGBackend +import cairosvg +from PIL import Image + +logger = logging.getLogger(__name__) + +RENDER_PX = 8000 # target pixel size of the longer edge — Claude vision max + + +def dwg_to_dxf(dwg_path: Path, out_dir: Path) -> Path: + dxf_path = out_dir / f"{dwg_path.stem}.dxf" + r = subprocess.run( + ["dwgread", "-O", "DXF", "-o", str(dxf_path), str(dwg_path)], + capture_output=True, text=True, timeout=180, + ) + if not dxf_path.exists(): + raise RuntimeError(f"DWG→DXF failed (exit {r.returncode}): {r.stderr or r.stdout}") + return dxf_path + + +SKIP_LAYER_PATTERNS = ( + "dimens", "kota", "kóta", "koty", # dimensions + "sit_konst", "konstrukce", "ckkoty", # structural / dimensioning + "ckprofi", "profily", "csprofily", # steel/concrete profile rezy + "sanita", "vzt", "ov_kan", "tzb", # plumbing / HVAC (when not the target) + "viewport", "defpoints", "0_", "_b_", # CAD bookkeeping + "raster", "wipeout", +) + + +def _clean_doc(doc, drop_layers=False): + """Strip elements that overwhelm vision rendering.""" + msp = doc.modelspace() + valid_blocks = {b.name for b in doc.blocks} + for e in list(msp.query("INSERT")): + if e.dxf.name not in valid_blocks: + msp.delete_entity(e) + for typ in ("HATCH", "SOLID", "MPOLYGON"): + for e in list(msp.query(typ)): + msp.delete_entity(e) + if drop_layers: + for e in list(msp): + layer = str(getattr(e.dxf, "layer", "")).lower() + if any(p in layer for p in SKIP_LAYER_PATTERNS): + try: + msp.delete_entity(e) + except Exception: + pass + for e in msp: + try: + e.dxf.lineweight = 5 + except Exception: + pass + + +def find_floors(dxf_path: Path) -> list[dict]: + """Find legend 'LEGENDA' markers — each represents one floor. + + Returns list of {legend_xy, floor_bbox} dicts ordered top to bottom. + """ + doc = ezdxf.readfile(str(dxf_path)) + msp = doc.modelspace() + + positions = [] + for e in msp: + text = "" + if e.dxftype() == "MTEXT": + text = e.text + elif e.dxftype() == "TEXT": + text = e.dxf.text + if text and text.strip().upper() == "LEGENDA": + try: + positions.append((e.dxf.insert.x, e.dxf.insert.y)) + except Exception: + pass + positions.sort(key=lambda p: -p[1]) + + if not positions: + return [{"legend_xy": None, "floor_bbox": None}] + + # Use the same cleanup as render_region so extents reflect what'll be drawn + _clean_doc(doc) + from ezdxf.bbox import extents + try: + ext = extents(msp, fast=True) + mxmin, mymin = ext.extmin.x, ext.extmin.y + mxmax, mymax = ext.extmax.x, ext.extmax.y + except Exception: + mxmin, mymin = -1e9, -1e9 + mxmax, mymax = 1e9, 1e9 + logger.info("find_floors: model extents x=(%.0f,%.0f) y=(%.0f,%.0f)", + mxmin, mxmax, mymin, mymax) + + floors = [] + for i, (lx, ly) in enumerate(positions): + if i + 1 < len(positions): + y_height = ly - positions[i + 1][1] + elif i > 0: + y_height = positions[i - 1][1] - ly + else: + y_height = 60000 + # Legend appears at the TOP of its floor view; plan extends downward + y_top = min(mymax, ly + 0.10 * y_height) + y_bot = max(mymin, ly - 0.95 * y_height) + # X span = whole model width (floor plans typically span the page width) + x_left = mxmin + x_right = mxmax + floors.append({ + "legend_xy": (lx, ly), + "floor_bbox": (x_left, y_bot, x_right, y_top), + }) + return floors + + +def render_region(dxf_path: Path, out_path: Path, bbox: tuple | None) -> Path: + """Render a DXF (optionally clipped to bbox) to PNG via SVG. + + Pipeline: ezdxf → SVG (vector, faithful linework) → cairosvg → PNG. + Cropping is done in pixel space after rasterization, using the page + rectangle ezdxf assigned to the SVG. + """ + doc = ezdxf.readfile(str(dxf_path)) + auditor = doc.audit() + if auditor.has_errors: + logger.info("DXF audit: %d errors", len(auditor.errors)) + _clean_doc(doc) + msp = doc.modelspace() + + from ezdxf.bbox import extents + try: + ext = extents(msp, fast=True) + model_xmin = ext.extmin.x + model_ymin = ext.extmin.y + model_w = ext.size.x or 1 + model_h = ext.size.y or 1 + except Exception: + model_xmin = model_ymin = 0 + model_w = model_h = 1 + + config = Configuration( + background_policy=BackgroundPolicy.WHITE, + color_policy=ColorPolicy.BLACK, + lineweight_scaling=0.5, + min_lineweight=0.05, + ) + + # ezdxf SVG: target page sized so longest dimension is RENDER_PX pixels. + # SVG uses mm; pick a scale such that the page fits cleanly. + aspect = model_w / max(model_h, 1) + if aspect >= 1: + page_w_mm = 1000 + page_h_mm = 1000 / aspect + else: + page_h_mm = 1000 + page_w_mm = 1000 * aspect + + page = layout.Page(width=page_w_mm, height=page_h_mm, + units=layout.Units.mm, margins=layout.Margins.all(0)) + backend = SVGBackend() + Frontend(RenderContext(doc), backend, config=config).draw_layout(msp, finalize=True) + svg_str = backend.get_string(page) + + # cairosvg renders SVG → PNG. output_width sets the PNG pixel width. + longest_px = RENDER_PX + out_width = longest_px if aspect >= 1 else int(longest_px * aspect) + png_bytes = cairosvg.svg2png(bytestring=svg_str.encode("utf-8"), + output_width=out_width) + img = Image.open(io.BytesIO(png_bytes)) + # Ensure white background (cairosvg may produce alpha) + if img.mode == "RGBA": + white = Image.new("RGB", img.size, (255, 255, 255)) + white.paste(img, mask=img.split()[3]) + img = white + + if bbox is not None: + W, H = img.size + xmin, ymin, xmax, ymax = bbox + logger.info("Crop input: model_w=%.0f model_h=%.0f W=%d H=%d bbox=%s", + model_w, model_h, W, H, bbox) + px0 = max(0, int((xmin - model_xmin) / model_w * W)) + px1 = min(W, int((xmax - model_xmin) / model_w * W)) + py0 = max(0, int((model_ymin + model_h - ymax) / model_h * H)) + py1 = min(H, int((model_ymin + model_h - ymin) / model_h * H)) + logger.info("Crop pixels: (%d,%d) → (%d,%d)", px0, py0, px1, py1) + if px1 > px0 and py1 > py0: + img = img.crop((px0, py0, px1, py1)) + + if max(img.size) > RENDER_PX: + r = RENDER_PX / max(img.size) + img = img.resize((int(img.size[0] * r), int(img.size[1] * r)), Image.LANCZOS) + + img.save(out_path, "PNG", optimize=True) + logger.info("Rendered → %s (%dx%d)", out_path.name, img.size[0], img.size[1]) + return out_path + + +def render(input_path: Path, out_dir: Path) -> dict: + """Convert input → list of floor images. + + Returns: {"floors": [{"index":0, "png":"floor_0.png", "legend_xy":[x,y]}, ...]}. + """ + suffix = input_path.suffix.lower() + + if suffix == ".pdf": + return _render_pdf(input_path, out_dir) + + if suffix == ".dwg": + dxf_path = dwg_to_dxf(input_path, out_dir) + elif suffix == ".dxf": + dxf_path = input_path + else: + raise ValueError(f"Unsupported format: {suffix}") + + floors = find_floors(dxf_path) + logger.info("Detected %d floor(s) via LEGENDA markers", len(floors)) + # MVP: render only the first floor. Multi-floor selection is a follow-up. + f = floors[0] + png = out_dir / "floor_0.png" + render_region(dxf_path, png, f["floor_bbox"]) + return {"floors": [{"index": 0, "png": png.name, + "legend_xy": list(f["legend_xy"]) if f["legend_xy"] else None}]} + + +def _render_pdf(pdf_path: Path, out_dir: Path) -> dict: + """Render PDF → PNG, auto-rotate so LEGENDA reads horizontally. + + Uses pdfplumber to find 'LEGENDA' text and its rotation, then renders + via pdf2image and applies image rotation so the legend is upright. + Returns the same shape as the DXF render path. + """ + from pdf2image import convert_from_path + import pdfplumber + # Allow large rasterizations (architectural PDFs can be 200M+ px at high DPI) + Image.MAX_IMAGE_PIXELS = None + + # Pick DPI so longest page edge lands near RENDER_PX pixels + with pdfplumber.open(str(pdf_path)) as pdf: + first = pdf.pages[0] + pw_in = max(first.width, first.height) / 72 # PDF points → inches + target_dpi = max(150, min(600, int(RENDER_PX / max(pw_in, 1)))) + logger.info("PDF page longest edge %.1f in → using dpi=%d", pw_in, target_dpi) + + pages = convert_from_path(str(pdf_path), dpi=target_dpi) + out_paths = [] + legend_info: list[dict] = [] + + with pdfplumber.open(str(pdf_path)) as pdf: + for i, plumb_page in enumerate(pdf.pages): + page_img = pages[i] if i < len(pages) else None + if page_img is None: + continue + pw, ph = plumb_page.width, plumb_page.height + iw, ih = page_img.size + # Find any text matching legend headings + words = plumb_page.extract_words(extra_attrs=["upright"]) or [] + legend_word = None + for w in words: + text = w["text"].strip().upper() + if text in ("LEGENDA", "VYSVĚTLIVKY", "LEGENDA:", "POPIS"): + legend_word = w + break + rotation = 0 + if legend_word is not None and not legend_word.get("upright", True): + # Sideways text → rotate the image so text is upright. + # PIL.rotate uses CCW for positive angles. + rotation = 90 + page_img = page_img.rotate(90, expand=True) + iw, ih = page_img.size + logger.info("PDF page %d: rotated 90° CCW (LEGENDA was sideways)", i) + if legend_word is not None: + # Convert PDF coords to NORMALIZED image coords (after rotation) + x0, y0 = legend_word["x0"], legend_word["top"] + x1, y1 = legend_word["x1"], legend_word["bottom"] + if rotation == 90: + # CCW 90° rotation: original (x, y) → new (y, W-x). + # Rotated image has width=ph, height=pw. + nx0 = y0 / ph + nx1 = y1 / ph + ny0 = 1 - (x1 / pw) + ny1 = 1 - (x0 / pw) + else: + nx0, nx1 = x0 / pw, x1 / pw + ny0, ny1 = y0 / ph, y1 / ph + legend_info.append({"page": i, "norm_bbox": (nx0, ny0, nx1, ny1)}) + logger.info("PDF page %d: LEGENDA at norm bbox %s", + i, (nx0, ny0, nx1, ny1)) + + if max(page_img.size) > RENDER_PX: + r = RENDER_PX / max(page_img.size) + page_img = page_img.resize( + (int(page_img.size[0] * r), int(page_img.size[1] * r)), + Image.LANCZOS, + ) + p = out_dir / f"floor_{i}.png" + page_img.save(p, "PNG", optimize=True) + out_paths.append(p) + + return { + "floors": [ + {"index": i, "png": p.name, "legend_xy": None, + "legend_norm_bbox": next((li["norm_bbox"] for li in legend_info + if li["page"] == i), None)} + for i, p in enumerate(out_paths) + ] + } + + +def crop_region(image_path: Path, bbox: dict, out_path: Path, + pad: float = 1.5, min_px: int = 120) -> Path: + """Crop a region with generous padding so symbols are visible. + + pad: multiplier of the bbox half-extent added on each side. + min_px: ensure the output is at least this many pixels wide/tall by + expanding the crop region if the requested area is smaller. + """ + img = Image.open(image_path) + W, H = img.size + bx, by, bw, bh = bbox["x"], bbox["y"], bbox["w"], bbox["h"] + cx, cy = bx + bw / 2, by + bh / 2 + # Padded extents in normalized coords + half_w = bw / 2 + bw * pad + half_h = bh / 2 + bh * pad + # Enforce minimum pixel dimensions + if (2 * half_w) * W < min_px: + half_w = (min_px / 2) / W + if (2 * half_h) * H < min_px: + half_h = (min_px / 2) / H + x0 = max(0, int((cx - half_w) * W)) + x1 = min(W, int((cx + half_w) * W)) + y0 = max(0, int((cy - half_h) * H)) + y1 = min(H, int((cy + half_h) * H)) + crop = img.crop((x0, y0, x1, y1)) + crop.save(out_path, "PNG") + return out_path diff --git a/dwg-counting/requirements.txt b/dwg-counting/requirements.txt new file mode 100644 index 0000000..23f0bd0 --- /dev/null +++ b/dwg-counting/requirements.txt @@ -0,0 +1,16 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +ezdxf>=1.3 +matplotlib>=3.8 +cairosvg>=2.7 +Pillow>=10.0 +pdf2image>=1.17 +pdfplumber>=0.11 +opencv-python-headless>=4.10 +numpy>=1.26 +reportlab>=4.0 +openpyxl>=3.1 +python-multipart>=0.0.9 +openai>=1.50 +python-dotenv>=1.0 +aiofiles>=23.0 diff --git a/dwg-counting/static/app.js b/dwg-counting/static/app.js new file mode 100644 index 0000000..3e046db --- /dev/null +++ b/dwg-counting/static/app.js @@ -0,0 +1,442 @@ +// dwg-counting frontend +(() => { + const $ = (id) => document.getElementById(id); + const sections = { + upload: $("s-upload"), + processing: $("s-processing"), + symbols: $("s-symbols"), + results: $("s-results"), + }; + const show = (name) => { + for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name); + }; + + let state = { jobId: null, symbols: [], selected: new Set(), results: [] }; + + // Upload handlers + const fileInput = $("file-input"); + const dropZone = $("drop-zone"); + $("browse-btn").addEventListener("click", () => fileInput.click()); + fileInput.addEventListener("change", (e) => e.target.files[0] && upload(e.target.files[0])); + ["dragenter", "dragover"].forEach((ev) => + dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); })); + ["dragleave", "drop"].forEach((ev) => + dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); })); + dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && upload(e.dataTransfer.files[0])); + + async function upload(file) { + show("processing"); + $("processing-title").textContent = "Připravuji výkres…"; + const fd = new FormData(); + fd.append("file", file); + try { + // Skip auto-detection by default — user defines symbols manually + const r = await fetch("/api/upload?auto_detect=false", { + method: "POST", body: fd, + }); + if (!r.ok) throw new Error((await r.json()).detail || r.statusText); + const data = await r.json(); + state.jobId = data.job_id; + state.symbols = data.symbols || []; + state.selected = new Set(); + renderSymbols(); + show("symbols"); + } catch (err) { + alert("Chyba: " + err.message); + show("upload"); + } + } + + $("auto-detect-btn").addEventListener("click", async () => { + if (!state.jobId) return; + show("processing"); + $("processing-title").textContent = "Hledám legendu pomocí AI…"; + try { + const r = await fetch(`/api/auto-detect/${state.jobId}`, { method: "POST" }); + if (!r.ok) throw new Error((await r.json()).detail || r.statusText); + const data = await r.json(); + state.symbols = data.symbols || []; + renderSymbols(); + show("symbols"); + } catch (err) { + alert("Chyba: " + err.message); + show("symbols"); + } + }); + + $("add-symbol-btn").addEventListener("click", () => { + openAddSymbolModal(); + }); + + $("upload-symbol-btn").addEventListener("click", () => { + $("symbol-file-input").click(); + }); + $("symbol-file-input").addEventListener("change", async (e) => { + const f = e.target.files[0]; + if (!f) return; + const name = prompt("Název symbolu:", f.name.replace(/\.[a-z]+$/i, "")); + if (!name) return; + const fd = new FormData(); + fd.append("file", f); + fd.append("description", name); + try { + const r = await fetch(`/api/symbols/${state.jobId}/upload`, { method: "POST", body: fd }); + if (!r.ok) throw new Error((await r.json()).detail || r.statusText); + const sym = await r.json(); + state.symbols.push(sym); + renderSymbols(); + } catch (err) { alert("Chyba: " + err.message); } + e.target.value = ""; + }); + + function renderSymbols() { + const meta = $("symbols-meta"); + if (!state.symbols.length) { + meta.textContent = 'Klikněte „+ Přidat symbol" a vyznačte ve výkresu, co chcete počítat. Nebo zkuste „Auto-detekce z legendy" pro automatický návrh.'; + $("symbols-grid").innerHTML = ""; + return; + } + meta.textContent = `${state.symbols.length} symbolů. Zaškrtněte k spočítání, ✎ upravit, 🗑 smazat.`; + const grid = $("symbols-grid"); + grid.innerHTML = ""; + for (const s of state.symbols) { + const card = document.createElement("div"); + card.className = "symbol-card"; + card.innerHTML = ` + + +
+
${s.id}
+
${escapeHtml(s.description || "")}
+
+ + + `; + const cb = card.querySelector("input"); + cb.addEventListener("change", () => { + if (cb.checked) { state.selected.add(s.id); card.classList.add("selected"); } + else { state.selected.delete(s.id); card.classList.remove("selected"); } + $("count-btn").disabled = state.selected.size === 0; + }); + // Click on row toggles checkbox (except on edit btn / thumb) + card.addEventListener("click", (e) => { + if (e.target.closest(".symbol-edit") || e.target === cb) return; + cb.checked = !cb.checked; + cb.dispatchEvent(new Event("change")); + }); + card.querySelector(".symbol-edit").addEventListener("click", (e) => { + e.stopPropagation(); + openCropModal(s); + }); + card.querySelector(".symbol-debug").addEventListener("click", (e) => { + e.stopPropagation(); + openDebugModal(s); + }); + card.querySelector(".symbol-delete").addEventListener("click", async (e) => { + e.stopPropagation(); + if (!confirm(`Smazat symbol "${s.description || s.id}"?`)) return; + try { + const r = await fetch(`/api/symbols/${state.jobId}/${s.id}`, { method: "DELETE" }); + if (!r.ok) throw new Error("delete failed"); + state.symbols = state.symbols.filter(x => x.id !== s.id); + state.selected.delete(s.id); + renderSymbols(); + $("count-btn").disabled = state.selected.size === 0; + } catch (err) { alert("Chyba: " + err.message); } + }); + grid.appendChild(card); + } + } + + // Add-new-symbol modal: same crop modal, but uses full drawing image and + // asks for a name. + function openAddSymbolModal() { + const modal = $("crop-modal"); + const img = $("crop-img"); + const sel = $("crop-selection"); + const wrap = $("crop-canvas-wrap"); + $("crop-modal-title").textContent = "Nový symbol — vyznačte oblast ve výkresu"; + $("crop-modal-hint").textContent = + "DOPORUČENO: najděte symbol PŘÍMO ve výkresu (ne v legendě) — barvy a tloušťka čar tam přesně odpovídají tomu, co budeme hledat. Vyznačte těsný rámeček kolem jedné instance symbolu a pojmenujte ho."; + $("crop-name-row").classList.remove("hidden"); + $("crop-name").value = ""; + img.src = `/api/drawing/${state.jobId}`; + sel.style.display = "none"; + $("crop-save").disabled = true; + cropState = { mode: "create", sym: null, bbox: null, dragging: false }; + modal.classList.remove("hidden"); + attachCropHandlers(wrap, img, sel); + } + + // ── Crop modal logic ────────────────────── + let cropState = null; + + function openCropModal(sym) { + const modal = $("crop-modal"); + const img = $("crop-img"); + const sel = $("crop-selection"); + const wrap = $("crop-canvas-wrap"); + $("crop-modal-title").textContent = `Upravit symbol: ${sym.description || sym.id}`; + $("crop-modal-hint").textContent = + "Označte oblast obsahující pouze grafický symbol (bez popisu)."; + $("crop-name-row").classList.add("hidden"); + // For editing: use the full drawing so user can pick any instance + img.src = `/api/drawing/${state.jobId}`; + sel.style.display = "none"; + $("crop-save").disabled = true; + cropState = { mode: "edit", sym, bbox: null, dragging: false }; + modal.classList.remove("hidden"); + attachCropHandlers(wrap, img, sel); + return; + } + + function attachCropHandlers(wrap, img, sel) { + + // Mouse coords relative to the IMAGE's top-left (in image-pixel space, + // which is also wrap's content space since image is at native scale and + // sits at content origin (0,0) in the relative-positioned wrap). + function localXY(e) { + const rect = img.getBoundingClientRect(); + return { x: e.clientX - rect.left, y: e.clientY - rect.top }; + } + + function onMouseDown(e) { + const p = localXY(e); + cropState.startX = p.x; + cropState.startY = p.y; + cropState.dragging = true; + sel.style.display = "block"; + sel.style.left = p.x + "px"; + sel.style.top = p.y + "px"; + sel.style.width = "0px"; + sel.style.height = "0px"; + } + function onMouseMove(e) { + if (!cropState || !cropState.dragging) return; + const p = localXY(e); + const left = Math.min(p.x, cropState.startX); + const top = Math.min(p.y, cropState.startY); + const w = Math.abs(p.x - cropState.startX); + const h = Math.abs(p.y - cropState.startY); + sel.style.left = left + "px"; + sel.style.top = top + "px"; + sel.style.width = w + "px"; + sel.style.height = h + "px"; + } + function onMouseUp() { + if (!cropState) return; + cropState.dragging = false; + const rect = img.getBoundingClientRect(); + const dispW = rect.width, dispH = rect.height; + const left = parseFloat(sel.style.left), top = parseFloat(sel.style.top); + const w = parseFloat(sel.style.width), h = parseFloat(sel.style.height); + if (w < 5 || h < 5) { + sel.style.display = "none"; + $("crop-save").disabled = true; + return; + } + cropState.bbox = { + x: left / dispW, + y: top / dispH, + w: w / dispW, + h: h / dispH, + }; + $("crop-save").disabled = false; + } + + wrap.onmousedown = onMouseDown; + wrap.onmousemove = onMouseMove; + wrap.onmouseup = onMouseUp; + wrap.onmouseleave = onMouseUp; + } + + function closeCropModal() { + $("crop-modal").classList.add("hidden"); + cropState = null; + } + + async function openDebugModal(sym) { + const modal = $("debug-modal"); + const body = $("debug-body"); + $("debug-title").textContent = `Debug: ${sym.description || sym.id}`; + body.innerHTML = '

Načítám diagnostiku…

'; + modal.classList.remove("hidden"); + try { + const r = await fetch(`/api/debug/${state.jobId}/${sym.id}`); + if (!r.ok) throw new Error(await r.text()); + const info = await r.json(); + const tmplURL = `/api/symbol/${state.jobId}/${sym.id}?v=${sym._v||0}`; + const procURL = `/api/debug-template/${state.jobId}/${sym.id}?v=${sym._v||0}&t=${Date.now()}`; + const drawURL = `/api/drawing/${state.jobId}`; + const matches = info.matches_at_threshold || {}; + const maxMatchCount = Math.max(1, ...Object.values(matches)); + const threshRows = Object.entries(matches).map(([t, n]) => ` +
práh ${t}
+
+
${n} shod
`).join(""); + const inkPct = info.template_total_pixels + ? ((info.template_ink_pixels / info.template_total_pixels) * 100).toFixed(1) + : "?"; + body.innerHTML = ` +
+

Obrazy

+
+
+ + Šablona (uložená) +
+
+ + Po předzpracování
(co matcher vidí)
+
+
+ otevřít celý výkres ↗ + ${info.drawing_size.join(" × ")} px +
+
+
+
+

Měření

+
+ Šablona originál${info.template_size.join(" × ")} px + Šablona po ořezu${info.template_cropped_size.join(" × ")} px + Inkoust v šabloně${info.template_ink_pixels} / ${info.template_total_pixels} px (${inkPct}%) + Výkres${info.drawing_size.join(" × ")} px + Nejlepší skóre${info.max_score?.toFixed(3) ?? "—"} +
+
+
+

Shody při různých prazích (bez rotací, scale 1.0)

+
${threshRows}
+

+ Aktuálně používaný práh pro počítání: 0.75. + Skutečné počty (s rotacemi 0/90/180/270° a třemi scale faktory) jsou typicky nižší kvůli dedupli­kaci. +

+
+
+ `; + // Heuristic interpretation + const interpret = []; + if (info.template_ink_pixels < 30) + interpret.push('⚠ Šablona obsahuje příliš málo „inkoustu" — pravděpodobně jste vybrali převážně bílou plochu. Vyznačte rámeček těsně kolem čar symbolu.'); + if (Math.min(...info.template_cropped_size) < 12) + interpret.push("⚠ Šablona je velmi malá (<12 px). Malé šablony hlásí spoustu falešných shod nebo žádné. Zkuste přidat trochu okolí."); + if ((info.max_score ?? 0) < 0.5) + interpret.push("⚠ Nejlepší skóre v celém výkresu je velmi nízké — symbol vypadá v plánu jinak než v šabloně. Zkuste vyznačit symbol přímo z plánu (ne z legendy), nebo zkuste jinou variantu/orientaci."); + else if ((info.max_score ?? 0) < 0.7) + interpret.push("ℹ Nejlepší skóre ≈ " + info.max_score.toFixed(2) + ". Práh 0.75 je nad maximem — zkuste snížit práh nebo upravit šablonu těsněji."); + else + interpret.push("✓ Symbol se v plánu vyskytuje. Pokud nejsou shody, může jít o problém s rotacemi nebo měřítkem."); + $("debug-interpret").innerHTML = "

Interpretace

" + + interpret.map(t => `

${t}

`).join(""); + } catch (err) { + body.innerHTML = `

Chyba: ${err.message}

`; + } + } + + $("debug-close").addEventListener("click", () => $("debug-modal").classList.add("hidden")); + $("debug-ok").addEventListener("click", () => $("debug-modal").classList.add("hidden")); + + $("crop-close").addEventListener("click", closeCropModal); + $("crop-cancel").addEventListener("click", closeCropModal); + $("crop-save").addEventListener("click", async () => { + if (!cropState || !cropState.bbox) return; + try { + if (cropState.mode === "create") { + const name = ($("crop-name").value || "").trim(); + if (!name) { alert("Zadejte název symbolu."); return; } + const r = await fetch(`/api/symbols/${state.jobId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + bbox: cropState.bbox, + description: name, + source: "drawing", + }), + }); + if (!r.ok) throw new Error((await r.json()).detail || r.statusText); + const newSym = await r.json(); + state.symbols.push(newSym); + } else { + const r = await fetch(`/api/symbol/${state.jobId}/${cropState.sym.id}/recrop`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ bbox: cropState.bbox }), + }); + if (!r.ok) throw new Error((await r.json()).detail || r.statusText); + cropState.sym._v = (cropState.sym._v || 0) + 1; + } + renderSymbols(); + closeCropModal(); + } catch (err) { + alert("Chyba: " + err.message); + } + }); + + // Threshold slider live readout + const thrSlider = $("threshold-slider"); + const thrValue = $("threshold-value"); + thrSlider.addEventListener("input", () => { + thrValue.textContent = parseFloat(thrSlider.value).toFixed(2); + }); + + $("count-btn").addEventListener("click", async () => { + show("processing"); + $("processing-title").textContent = `Počítám ${state.selected.size} symbolů…`; + try { + const r = await fetch(`/api/count/${state.jobId}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + symbol_ids: [...state.selected], + threshold: parseFloat(thrSlider.value), + }), + }); + if (!r.ok) throw new Error((await r.json()).detail || r.statusText); + const data = await r.json(); + state.results = data.results || []; + renderResults(); + show("results"); + } catch (err) { + alert("Chyba: " + err.message); + show("symbols"); + } + }); + + function renderResults() { + const total = state.results.reduce((a, r) => a + (r.count || 0), 0); + $("results-meta").textContent = `Celkem ${total} symbolů ve ${state.results.length} kategoriích.`; + const tb = $("results-tbody"); + tb.innerHTML = ""; + for (const r of state.results) { + const tr = document.createElement("tr"); + const conf = r.confidence || ""; + tr.innerHTML = ` + + ${escapeHtml(r.description || "")} + ${r.count} + ${conf || "—"} + ${escapeHtml(r.notes || "")}`; + tb.appendChild(tr); + } + } + + $("export-btn").addEventListener("click", () => { + window.location.href = `/api/export/${state.jobId}`; + }); + $("pdf-btn").addEventListener("click", () => { + window.location.href = `/api/export-pdf/${state.jobId}`; + }); + + $("back-btn").addEventListener("click", () => show("symbols")); + $("reset-btn").addEventListener("click", () => { + state = { jobId: null, symbols: [], selected: new Set(), results: [] }; + fileInput.value = ""; + show("upload"); + }); + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); + } +})(); diff --git a/dwg-counting/static/extra.css b/dwg-counting/static/extra.css new file mode 100644 index 0000000..4a86a76 --- /dev/null +++ b/dwg-counting/static/extra.css @@ -0,0 +1,301 @@ +/* Extra styles specific to dwg-counting */ + +.back-link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px 6px 10px; + border-radius: 8px; + font-size: 13px; + font-weight: 500; + color: var(--text-tertiary); + text-decoration: none; + border: 0.5px solid var(--border-default); + background: var(--bg-primary); + transition: color 0.15s, border-color 0.15s, background 0.15s; + flex-shrink: 0; +} +.back-link:hover { + color: var(--primary); + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); +} +.back-link svg { opacity: 0.8; } +@media (max-width: 640px) { + .back-link span { display: none; } + .back-link { padding: 6px; } + .back-link svg { width: 16px; height: 16px; } +} + +.processing-sub { + font-size: 13px; + color: var(--text-tertiary); + margin-top: 8px; +} + +.symbols-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 12px; + margin-top: 16px; +} + +.symbol-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 12px; + display: flex; + gap: 12px; + align-items: center; + cursor: pointer; + transition: border-color .15s, box-shadow .15s; +} +.symbol-card:hover { + border-color: var(--primary); +} +.symbol-card.selected { + border-color: var(--primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 25%, transparent); +} + +.symbol-card input[type=checkbox] { + width: 18px; + height: 18px; + flex-shrink: 0; + cursor: pointer; + accent-color: var(--primary); +} + +.symbol-thumb { + width: 64px; + height: 64px; + background: #fff; + border: 1px solid var(--border-default); + border-radius: 6px; + object-fit: contain; + padding: 4px; + flex-shrink: 0; +} + +.symbol-info { + flex: 1; + min-width: 0; +} +.symbol-id { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-tertiary); + font-weight: 600; +} +.symbol-desc { + font-size: 13px; + color: var(--text-primary); + margin-top: 2px; + line-height: 1.35; +} + +.threshold-row { + display: flex; + align-items: center; + gap: 12px; + margin: 12px 0; + padding: 10px 14px; + background: var(--bg-tertiary); + border-radius: 8px; + font-size: 13px; + flex-wrap: wrap; +} +.threshold-row label { font-weight: 600; } +.threshold-row input[type=range] { + flex: 0 0 240px; + accent-color: var(--primary); +} +#threshold-value { + display: inline-block; + min-width: 40px; + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var(--primary); +} +.threshold-hint { color: var(--text-tertiary); font-size: 12px; flex: 1; } + +.confidence-high { color: #16a34a; font-weight: 600; } +.confidence-medium { color: #d97706; font-weight: 600; } +.confidence-low { color: #dc2626; font-weight: 600; } + +/* Edit button on symbol cards */ +.symbol-edit { + background: transparent; + border: 1px solid var(--border-default); + border-radius: 6px; + padding: 4px 8px; + font-size: 11px; + color: var(--text-secondary); + cursor: pointer; + flex-shrink: 0; +} +.symbol-edit:hover { + border-color: var(--primary); + color: var(--primary); +} + +/* Crop modal */ +.modal { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 100; + display: flex; + align-items: center; + justify-content: center; +} +.modal-content { + background: var(--card); + border-radius: var(--radius-lg); + padding: 20px; + max-width: 90vw; + max-height: 90vh; + display: flex; + flex-direction: column; +} +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} +.btn-close { + background: transparent; + border: none; + font-size: 28px; + line-height: 1; + cursor: pointer; + color: var(--text-tertiary); +} +.modal-hint { + font-size: 13px; + color: var(--text-tertiary); + margin-bottom: 12px; +} +.crop-canvas-wrap { + position: relative; + overflow: auto; + max-height: 65vh; + border: 1px solid var(--border-default); + background: #fff; + cursor: crosshair; +} +#crop-img { + display: block; + max-width: none; + user-select: none; + pointer-events: none; +} +#crop-selection { + position: absolute; + border: 2px dashed var(--primary); + background: rgba(21, 90, 239, 0.08); + pointer-events: none; + display: none; +} +.modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 12px; +} +.crop-name-row { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 10px; +} +.crop-name-row label { + font-size: 13px; + font-weight: 600; +} +.crop-name-row input { + flex: 1; + padding: 6px 10px; + border: 1px solid var(--border-default); + border-radius: 6px; + font-size: 14px; +} +.symbol-delete { + background: transparent; + border: 1px solid transparent; + color: var(--text-tertiary); + font-size: 14px; + padding: 4px 8px; + cursor: pointer; + border-radius: 6px; + flex-shrink: 0; +} +.symbol-delete:hover { + color: #dc2626; + border-color: #dc2626; +} +.symbol-debug { + background: transparent; + border: 1px solid transparent; + color: var(--text-tertiary); + font-size: 14px; + padding: 4px 8px; + cursor: pointer; + border-radius: 6px; + flex-shrink: 0; +} +.symbol-debug:hover { + color: var(--primary); + border-color: var(--primary); +} + +.debug-body { + font-size: 13px; + line-height: 1.5; + color: var(--text-primary); + max-height: 70vh; + overflow-y: auto; +} +.debug-section { + margin-bottom: 14px; + padding-bottom: 14px; + border-bottom: 1px solid var(--border-default); +} +.debug-section:last-child { border-bottom: none; } +.debug-section h4 { + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-tertiary); + margin-bottom: 8px; +} +.debug-kv { display: grid; grid-template-columns: 220px 1fr; gap: 4px 12px; } +.debug-kv strong { color: var(--text-secondary); font-weight: 500; } +.debug-thumbs { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; } +.debug-thumb { + display: flex; flex-direction: column; align-items: center; + gap: 4px; min-width: 120px; +} +.debug-thumb a { + display: block; + border: 1px solid var(--border-default); + padding: 4px; + background: #fff; + cursor: zoom-in; +} +.debug-thumb img { display: block; max-width: 160px; max-height: 160px; } +.debug-thumb span { font-size: 11px; color: var(--text-tertiary); } +.debug-thresholds { display: grid; grid-template-columns: 60px 1fr 60px; gap: 4px 10px; align-items: center; } +.debug-bar { + height: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + overflow: hidden; +} +.debug-bar-fill { + height: 100%; + background: var(--primary); +} diff --git a/dwg-counting/static/index.html b/dwg-counting/static/index.html new file mode 100644 index 0000000..c92325c --- /dev/null +++ b/dwg-counting/static/index.html @@ -0,0 +1,205 @@ + + + + + + Počítání symbolů z PDF výkresu | Colsys AI + + + + + + + + + +
+
+ + C + Colsys AI + + Počítání symbolů z PDF výkresu + + + Zpět na portál + +
+
+ +
+ + +
+
+

Počítání symbolů z PDF výkresu

+

+ Nahrajte výkres ve formátu PDF, vyznačte symboly, které + chcete spočítat, a aplikace najde jejich výskyty v celém výkresu. + Výsledek lze stáhnout jako Excel nebo jako PDF s vyznačením. +

+
+ +
+ + + +

Přetáhněte PDF výkres sem

+

nebo

+ +

Podporovaný formát: .pdf

+ +
+
+ + + + + + + + + + + + + + + + +
+ + + + diff --git a/dwg-counting/static/styles.css b/dwg-counting/static/styles.css new file mode 100644 index 0000000..9efda74 --- /dev/null +++ b/dwg-counting/static/styles.css @@ -0,0 +1,461 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f2f4f7; + --text-primary: #101828; + --text-secondary: #354052; + --text-tertiary: #676f83; + --text-quaternary: #98a2b2; + --border-default: rgb(16 24 40 / 0.08); + --border-strong: #d0d5dc; + --border-subtle: rgb(16 24 40 / 0.04); + --card: #ffffff; + --primary: #155aef; + --primary-hover: #004aeb; + --accent-indigo: #444ce7; + --shadow-card: 0 1px 2px rgb(16 24 40 / 0.05); + --radius-md: 8px; + --radius-lg: 12px; +} + +/* Dark theme — applies when (a) user OS prefers dark and no .light override, + or (b) :root has explicit .dark class (set by portal_theme cookie). */ +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; + } +} +:root.dark { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; +} + +body { + font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 100vh; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Header ─────────────────────────────────────── */ +.header { + position: sticky; + top: 0; + z-index: 30; + border-bottom: 0.5px solid var(--border-default); + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + backdrop-filter: blur(8px); +} +.header-inner { + max-width: 1280px; /* match portal max-w-7xl so brand doesn't shift */ + margin: 0 auto; + height: 56px; /* portal uses h-14 */ + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; /* portal px-4 */ + gap: 16px; +} +@media (min-width: 640px) { + .header-inner { padding: 0 32px; } /* portal sm:px-8 */ +} +.brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} +.brand-icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%); + box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 12px; + letter-spacing: -0.04em; + color: white; +} +.brand-name { + font-size: 14px; /* tailwind text-sm */ + font-weight: 600; + letter-spacing: -0.025em; /* tailwind tracking-tight */ + color: var(--text-primary); +} +.brand-ai { color: var(--primary); } +.header-crumb { + font-size: 13px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Main ────────────────────────────────────────── */ +.main { + max-width: 900px; + margin: 0 auto; + padding: 40px 24px 80px; + display: flex; + flex-direction: column; + gap: 32px; +} +@media (max-width: 640px) { + .header-inner { padding: 0 16px; } + .main { padding: 24px 16px 60px; } +} + +/* ── Section intro ───────────────────────────────── */ +.section-intro { margin-bottom: 24px; } +.section-title { + font-size: 22px; + font-weight: 600; + letter-spacing: -0.02em; + margin-bottom: 8px; +} +.section-desc { + color: var(--text-tertiary); + max-width: 580px; + font-size: 14px; + line-height: 1.6; +} + +/* ── Examples panel ──────────────────────────────── */ +.examples-panel { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--card); + box-shadow: var(--shadow-card); + padding: 16px; + margin-bottom: 16px; +} +.examples-header { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 12px; +} +.examples-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} +.examples-subtitle { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; +} +.examples-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 28px; + margin-bottom: 10px; +} +.examples-list:empty::before { + content: "Zatím žádné vzory — přidejte alespoň jeden níže"; + font-size: 12px; + color: var(--text-quaternary); + font-style: italic; + padding: 6px 0; +} +.example-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent); + color: var(--text-primary); + padding: 4px 4px 4px 10px; + border-radius: 999px; + font-size: 12px; + font-family: ui-monospace, monospace; +} +.example-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-tertiary); + padding: 0; + transition: background 0.1s, color 0.1s; +} +.example-chip-remove:hover { + background: color-mix(in srgb, var(--primary) 18%, transparent); + color: var(--text-primary); +} +.example-chip-remove svg { + width: 12px; + height: 12px; +} +.examples-input-row { + display: flex; + gap: 8px; +} +.example-input { + flex: 1; + font-family: ui-monospace, monospace; + font-size: 13px; + padding: 8px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-primary); + min-width: 0; +} +.example-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} +.example-add-btn { + flex-shrink: 0; +} + +/* ── Drop zone ───────────────────────────────────── */ +.drop-zone { + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--card); + padding: 56px 32px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + box-shadow: var(--shadow-card); +} +.drop-zone:hover, .drop-zone.drag-over { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 4%, var(--card)); +} +.drop-icon { + width: 44px; + height: 44px; + color: var(--text-quaternary); + margin: 0 auto 18px; + display: block; + transition: color 0.15s; +} +.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon { + color: var(--primary); +} +.drop-text { + font-size: 15px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} +.drop-or { + font-size: 13px; + color: var(--text-quaternary); + margin-bottom: 14px; +} +.drop-formats { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 14px; +} + +/* ── Buttons ─────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.15s, box-shadow 0.15s; + line-height: 1; +} +.btn-primary { + background: var(--primary); + color: #fff; + box-shadow: 0 1px 2px rgb(21 90 239 / 0.25); +} +.btn-primary:hover { background: var(--primary-hover); } +.btn-primary:disabled { opacity: 0.5; cursor: default; } +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-strong); +} +.btn-secondary:hover { background: var(--border-strong); } +.btn-icon { width: 15px; height: 15px; flex-shrink: 0; } + +/* ── Processing card ─────────────────────────────── */ +.processing-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 56px 32px; + text-align: center; + box-shadow: var(--shadow-card); +} +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border-strong); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin: 0 auto 22px; +} +@keyframes spin { to { transform: rotate(360deg); } } +.processing-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 20px; + color: var(--text-primary); +} +.steps-list { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; + margin: 0 auto; + text-align: left; +} +.step-item { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text-tertiary); + padding: 8px 12px; + border-radius: var(--radius-md); + background: var(--bg-tertiary); +} +.step-item.active { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); +} +.step-item.done { color: #17b26a; } +.step-item.error { color: #d92d20; background: #fef3f2; } +.step-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +/* ── Results ─────────────────────────────────────── */ +.results-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.results-title { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.015em; + margin-bottom: 4px; +} +.results-meta { + font-size: 13px; + color: var(--text-tertiary); +} +.results-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ── Table ───────────────────────────────────────── */ +.table-wrap { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-card); + background: var(--card); + overflow-x: auto; +} +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + min-width: 500px; +} +thead { + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-default); +} +th { + padding: 10px 14px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + white-space: nowrap; +} +td { + padding: 8px 14px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-primary); + vertical-align: middle; +} +tr:last-child td { border-bottom: none; } +tr:nth-child(even) td { background: var(--bg-secondary); } +td[contenteditable]:focus { + outline: none; + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); + box-shadow: inset 0 0 0 1.5px var(--primary); + border-radius: 3px; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 11px; + font-weight: 500; +} +.badge-rule { background: #d1e0ff; color: #004aeb; } +.badge-llm { background: #d1fae5; color: #065f46; } +@media (prefers-color-scheme: dark) { + .badge-rule { background: #1e3a8a; color: #93c5fd; } + .badge-llm { background: #064e3b; color: #6ee7b7; } +} + +.table-hint { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 8px; +} + +.hidden { display: none !important; } diff --git a/dwg-counting/vision.py b/dwg-counting/vision.py new file mode 100644 index 0000000..3b68f1b --- /dev/null +++ b/dwg-counting/vision.py @@ -0,0 +1,121 @@ +"""Claude Sonnet 4 vision pipeline: legend detection + symbol counting.""" +import base64 +import json +import logging +import os +from pathlib import Path + +from openai import AsyncOpenAI + +logger = logging.getLogger(__name__) + +_client: AsyncOpenAI | None = None + + +def _get_client() -> AsyncOpenAI: + global _client + if _client is None: + _client = AsyncOpenAI( + base_url=os.getenv("LITELLM_BASE_URL", "http://litellm-proxy:4000/v1"), + api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"), + ) + return _client + + +def _b64(path: Path) -> str: + return base64.b64encode(path.read_bytes()).decode("ascii") + + +MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514") + + +LEGEND_PROMPT = """You are analyzing a Czech architectural / engineering drawing (HVAC, electrical, plumbing, fire safety, etc). + +NOTE: The drawing may be rotated 90° or 180°. Mentally rotate the page until text reads horizontally before searching. The legend is typically near a page edge or corner, often in a colored (yellow / red / green) text block that lists symbols and their meanings. + +Your task: find any legend / symbol key in this drawing. Possible Czech headings include "LEGENDA", "VYSVĚTLIVKY", "POPIS", "POPIS SYMBOLŮ", "POUŽITÉ ZNAČENÍ". A legend is a column-table where each row pairs a small graphical symbol with a Czech description. + +For each symbol entry found, return: +- `id`: short stable identifier (1-3 words, lowercase with underscores; e.g. "smoke_detector", "socket_230v", "valve_3way") +- `description`: full Czech description text exactly as written +- `bbox`: bounding box of THE SYMBOL ONLY (NOT the description text), normalized 0-1 coords relative to the full image. Format `{"x": 0.05, "y": 0.10, "w": 0.02, "h": 0.02}` where x,y is the top-left corner. + +Return ONLY valid JSON, no markdown, no commentary: +{"symbols": [{"id":"...","description":"...","bbox":{"x":0,"y":0,"w":0,"h":0}}]} + +If you genuinely cannot find any legend, return {"symbols": [], "reason": ""}. +Skip rows that are room schedules, material totals, or project info — only include rows showing actual graphical symbols with descriptions.""" + + +COUNT_PROMPT_TEMPLATE = """You are counting graphical symbols in a Czech architectural/engineering drawing. + +I will show you: +1. A reference symbol crop from the drawing's legend +2. The full drawing image + +Your task: count the number of times the reference symbol appears in the full drawing. Look only at the drawing area (not the legend itself). The symbol may be rotated 0°, 90°, 180°, 270° — count rotated instances. Ignore size variations within reason (the symbol scale should be similar). + +Reference symbol description (from legend): "{description}" + +Return ONLY valid JSON, no markdown: +{{"count": , "confidence": <"low"|"medium"|"high">, "notes": ""}}""" + + +async def detect_legend(image_path: Path) -> list[dict]: + """Pass 1: Find legend, return list of symbols with bbox.""" + img_b64 = _b64(image_path) + resp = await _get_client().chat.completions.create( + model=MODEL, + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": LEGEND_PROMPT}, + {"type": "image_url", + "image_url": {"url": f"data:image/png;base64,{img_b64}"}}, + ], + }], + max_tokens=4000, + temperature=0.0, + ) + raw = (resp.choices[0].message.content or "").strip() + logger.info("Legend raw response (first 800 chars): %s", raw[:800]) + raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + logger.error("Legend JSON parse failed: %s\nraw=%s", e, raw[:500]) + return [] + symbols = data.get("symbols", []) if isinstance(data, dict) else [] + logger.info("Legend detection found %d symbols", len(symbols)) + return symbols + + +async def count_symbol(symbol_crop: Path, full_image: Path, description: str) -> dict: + """Pass 2: Count instances of one symbol in the drawing.""" + crop_b64 = _b64(symbol_crop) + full_b64 = _b64(full_image) + prompt = COUNT_PROMPT_TEMPLATE.format(description=description) + resp = await _get_client().chat.completions.create( + model=MODEL, + messages=[{ + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + {"type": "text", "text": "Reference symbol:"}, + {"type": "image_url", + "image_url": {"url": f"data:image/png;base64,{crop_b64}"}}, + {"type": "text", "text": "Full drawing:"}, + {"type": "image_url", + "image_url": {"url": f"data:image/png;base64,{full_b64}"}}, + ], + }], + max_tokens=300, + temperature=0.0, + ) + raw = (resp.choices[0].message.content or "").strip() + raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + return json.loads(raw) + except json.JSONDecodeError: + logger.error("Count JSON parse failed: %s", raw[:200]) + return {"count": 0, "confidence": "low", "notes": "parse failed"} diff --git a/dwg-rooms/.dockerignore b/dwg-rooms/.dockerignore new file mode 100644 index 0000000..5bdf885 --- /dev/null +++ b/dwg-rooms/.dockerignore @@ -0,0 +1,6 @@ +.env +.git +__pycache__ +*.pyc +*.pyo +libredwg-0.12.5/ diff --git a/dwg-rooms/.env.example b/dwg-rooms/.env.example new file mode 100644 index 0000000..e24f396 --- /dev/null +++ b/dwg-rooms/.env.example @@ -0,0 +1,8 @@ +# Copy to .env and fill in values + +# LiteLLM proxy (leave LITELLM_API_KEY empty to disable LLM fallback) +LITELLM_BASE_URL=http://host.docker.internal:4000 +LITELLM_API_KEY= + +# Which LiteLLM model alias to use for the LLM fallback +LLM_MODEL=gpt-4o-mini diff --git a/dwg-rooms/Dockerfile b/dwg-rooms/Dockerfile new file mode 100644 index 0000000..a00c28f --- /dev/null +++ b/dwg-rooms/Dockerfile @@ -0,0 +1,38 @@ +# Stage 1: compile LibreDWG (provides dwg2dxf for DWG → DXF conversion) +FROM debian:bookworm-slim AS libredwg-builder + +RUN apt-get update && apt-get install -y --no-install-recommends \ + xz-utils \ + build-essential \ + autoconf \ + automake \ + libtool \ + python3 \ + && rm -rf /var/lib/apt/lists/* + +COPY libredwg-0.12.5.tar.xz . +RUN tar xf libredwg-0.12.5.tar.xz \ + && cd libredwg-0.12.5 \ + && ./configure --prefix=/opt/libredwg --disable-shared --disable-bindings \ + && make -j"$(nproc)" \ + && make install \ + && cd .. \ + && rm -rf libredwg-0.12.5 libredwg-0.12.5.tar.xz + +# Stage 2: runtime +FROM python:3.12-slim + +COPY --from=libredwg-builder /opt/libredwg/bin/dwgread /usr/local/bin/dwgread + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /tmp/dwg-rooms + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/dwg-rooms/docker-compose.yml b/dwg-rooms/docker-compose.yml new file mode 100644 index 0000000..887f3dc --- /dev/null +++ b/dwg-rooms/docker-compose.yml @@ -0,0 +1,24 @@ +services: + dwg-rooms: + build: . + container_name: dwg-rooms + restart: unless-stopped + ports: + - "127.0.0.1:3025:8000" + environment: + LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://host.docker.internal:4000} + LITELLM_API_KEY: ${LITELLM_API_KEY:-} + LLM_MODEL: ${LLM_MODEL:-gpt-4o-mini} + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - localai + volumes: + - dwg-rooms-data:/tmp/dwg-rooms + +volumes: + dwg-rooms-data: + +networks: + localai: + external: true diff --git a/dwg-rooms/excel_export.py b/dwg-rooms/excel_export.py new file mode 100644 index 0000000..3110a9e --- /dev/null +++ b/dwg-rooms/excel_export.py @@ -0,0 +1,48 @@ +"""Export room list to a styled Excel workbook.""" +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter + + +def export_to_excel(rooms: list[dict], output_path: str) -> None: + wb = Workbook() + ws = wb.active + ws.title = "Místnosti" + + hdr_fill = PatternFill("solid", fgColor="155AEF") + hdr_font = Font(bold=True, color="FFFFFF", size=10) + hdr_align = Alignment(horizontal="center", vertical="center") + row_border = Border( + bottom=Side(style="thin", color="D0D5DC"), + right=Side(style="thin", color="D0D5DC"), + ) + alt_fill = PatternFill("solid", fgColor="F9FAFB") + + headers = ["Č. místnosti", "Popis / účel", "Zdroj", "Spolehlivost"] + widths = [16, 52, 14, 16] + + for col, (hdr, w) in enumerate(zip(headers, widths), 1): + c = ws.cell(row=1, column=col, value=hdr) + c.font = hdr_font + c.fill = hdr_fill + c.alignment = hdr_align + ws.column_dimensions[get_column_letter(col)].width = w + + ws.row_dimensions[1].height = 22 + ws.freeze_panes = "A2" + + for row_num, room in enumerate(sorted(rooms, key=lambda r: str(r.get("room", ""))), 2): + values = [ + room.get("room", ""), + room.get("description", ""), + "Pravidla" if room.get("source") == "rule" else "AI", + f"{room.get('confidence', 1.0) * 100:.0f} %", + ] + for col, val in enumerate(values, 1): + c = ws.cell(row=row_num, column=col, value=val) + c.alignment = Alignment(vertical="center") + c.border = row_border + if row_num % 2 == 0: + c.fill = alt_fill + + wb.save(output_path) diff --git a/dwg-rooms/extractor.py b/dwg-rooms/extractor.py new file mode 100644 index 0000000..e4e4177 --- /dev/null +++ b/dwg-rooms/extractor.py @@ -0,0 +1,168 @@ +"""Rule-based room extraction from DXF files.""" +import math +import re + +import ezdxf + +# Defaults shown to user; they can remove or replace. +DEFAULT_EXAMPLES = ["č.m. 0301", "01024"] + +MEASUREMENT_KW = ( + "podlaha:", "stěny:", "strop:", "m2", "m²", "povrchová", "nátěr", + "penetrační", "vrstva", "odstín", "ral ", "chodníky:", "klenba:", + "portály:", "epoxidový", "beton", "dlažba", "omítka", "obklad", +) + + +def example_to_regex(example: str) -> re.Pattern | None: + """ + Convert an example like '4-22408' or 'č.m. 0301' into a compiled regex. + - digits become wildcards (\\d, exactly N digits where the example had N digits) + - everything else is matched literally + - an optional trailing letter is allowed (for variants like '0301a') + """ + if not example or not example.strip(): + return None + s = example.strip() + parts: list[str] = [] + run = 0 + for ch in s: + if ch.isdigit(): + run += 1 + continue + if run: + parts.append(rf"\d{{{run}}}") + run = 0 + parts.append(re.escape(ch)) + if run: + parts.append(rf"\d{{{run}}}") + pattern = "^(" + "".join(parts) + r"[a-zA-Z]?)$" + try: + return re.compile(pattern, re.IGNORECASE) + except re.error: + return None + + +def compile_examples(examples: list[str] | None) -> list[re.Pattern]: + items = examples if examples is not None else DEFAULT_EXAMPLES + out = [] + for ex in items: + rx = example_to_regex(ex) + if rx is not None: + out.append(rx) + return out + + +def _clean_mtext(text: str) -> str: + text = re.sub(r"\{\\[^;]*;", "", text) + text = re.sub(r"\\[Pp]", " ", text) + text = text.replace("}", "") + text = re.sub(r"\s*\d+[,\.]\d*\s*m2\s*$", "", text) + return " ".join(text.split()).strip() + + +def _is_measurement(text: str) -> bool: + t = text.lower() + return any(k in t for k in MEASUREMENT_KW) + + +def _is_room_marker(text: str, regexes: list[re.Pattern]) -> re.Match | None: + s = text.strip() + for rx in regexes: + m = rx.fullmatch(s) + if m: + return m + return None + + +def _is_dimension(text: str, regexes: list[re.Pattern]) -> bool: + """ + Anything that's effectively just digits (with optional separators) and is NOT + claimed by any active room pattern — treat as a dimension/measurement value. + """ + if _is_room_marker(text, regexes): + return False + clean = re.sub(r"[\s.,\-x×+]", "", text) + return clean.isdigit() and len(clean) > 0 + + +def _all_text_entities(dxf_path: str) -> list[dict]: + doc = ezdxf.readfile(dxf_path) + msp = doc.modelspace() + out = [] + for ent in msp: + try: + if ent.dxftype() == "TEXT": + t = ent.dxf.text.strip() + x, y = ent.dxf.insert.x, ent.dxf.insert.y + elif ent.dxftype() == "MTEXT": + t = _clean_mtext(ent.text) + x, y = ent.dxf.insert.x, ent.dxf.insert.y + else: + continue + if t: + out.append({"text": t, "x": x, "y": y}) + except Exception: + pass + return out + + +def _nearest_description(rx_x: float, ry: float, candidates: list[dict], + regexes: list[re.Pattern], max_dist: float = 8000) -> str | None: + best, best_d = None, max_dist + for c in candidates: + t = c["text"] + if _is_measurement(t) or _is_dimension(t, regexes) or len(t.strip()) < 2: + continue + if _is_room_marker(t, regexes): + continue + d = math.hypot(c["x"] - rx_x, c["y"] - ry) + if d < best_d: + best_d, best = d, t + return best + + +def extract_rooms(dxf_path: str, examples: list[str] | None = None) -> tuple[list[dict], list[dict]]: + """ + Returns (rooms, unmatched_texts). + examples: user-provided room-number examples; None → DEFAULT_EXAMPLES. + """ + regexes = compile_examples(examples) + entities = _all_text_entities(dxf_path) + + room_markers, other = [], [] + for e in entities: + m = _is_room_marker(e["text"], regexes) + if m: + room_markers.append({"room": m.group(1), "x": e["x"], "y": e["y"]}) + else: + other.append(e) + + used: set[str] = set() + seen_rooms: set[str] = set() + rooms: list[dict] = [] + for rm in room_markers: + if rm["room"] in seen_rooms: + continue + seen_rooms.add(rm["room"]) + desc = _nearest_description(rm["x"], rm["y"], other, regexes) + rooms.append({ + "room": rm["room"], + "description": desc or "", + "x": round(rm["x"], 1), + "y": round(rm["y"], 1), + "source": "rule", + "confidence": 1.0 if desc else 0.6, + }) + if desc: + used.add(desc) + + unmatched = [ + e for e in other + if e["text"] not in used + and not _is_measurement(e["text"]) + and not _is_dimension(e["text"], regexes) + and len(e["text"]) > 3 + ] + + return rooms, unmatched diff --git a/dwg-rooms/libredwg-0.12.5.tar.xz b/dwg-rooms/libredwg-0.12.5.tar.xz new file mode 100644 index 0000000..89f0806 Binary files /dev/null and b/dwg-rooms/libredwg-0.12.5.tar.xz differ diff --git a/dwg-rooms/llm_helper.py b/dwg-rooms/llm_helper.py new file mode 100644 index 0000000..35158aa --- /dev/null +++ b/dwg-rooms/llm_helper.py @@ -0,0 +1,80 @@ +"""LLM fallback: classify unmatched DXF text entities as rooms via LiteLLM.""" +import json +import logging +import os + +from openai import AsyncOpenAI + +logger = logging.getLogger(__name__) + +_client: AsyncOpenAI | None = None + + +def _get_client() -> AsyncOpenAI: + global _client + if _client is None: + _client = AsyncOpenAI( + base_url=os.getenv("LITELLM_BASE_URL", "http://host.docker.internal:4000"), + api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"), + ) + return _client + + +SYSTEM = """You are a specialist extracting room data from Czech architectural DXF floor plans. +You receive text entities (text, x, y) that were not matched by rule-based parsing. + +Identify pairs of room number + Czech room name/description. +Czech room numbers: 4-6 digit codes, sometimes prefixed with "č.m.". +Czech room names: e.g. "Chodba", "Serverovna", "Sklep", "WC", "Kancelář", etc. + +Return ONLY a JSON array of objects: +{"room": "XXXXX", "description": "Czech name", "confidence": 0.0-1.0} + +Skip: measurements (m2, m²), material names (beton, dlažba), dimensions, unrelated text. +Only include entries with confidence > 0.5.""" + + +async def enhance_with_llm(unmatched: list[dict]) -> list[dict]: + api_key = os.getenv("LITELLM_API_KEY", "") + if not api_key or api_key == "sk-dummy": + logger.info("LITELLM_API_KEY not set — skipping LLM enhancement") + return [] + + sample = unmatched[:200] + text_block = "\n".join( + f'- "{t["text"]}" x={t["x"]:.0f} y={t["y"]:.0f}' for t in sample + ) + model = os.getenv("LLM_MODEL", "gpt-4o-mini") + + try: + resp = await _get_client().chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": SYSTEM}, + {"role": "user", "content": f"Text entities:\n{text_block}"}, + ], + temperature=0.1, + max_tokens=2000, + ) + raw = resp.choices[0].message.content or "[]" + # Strip markdown code fences if present + raw = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```").strip() + data = json.loads(raw) + if isinstance(data, dict): + data = data.get("rooms", data.get("result", [])) + + return [ + { + "room": str(r["room"]), + "description": r.get("description", ""), + "x": 0.0, + "y": 0.0, + "source": "llm", + "confidence": float(r.get("confidence", 0.7)), + } + for r in data + if isinstance(r, dict) and r.get("room") + ] + except Exception as exc: + logger.error("LLM enhancement failed: %s", exc) + return [] diff --git a/dwg-rooms/main.py b/dwg-rooms/main.py new file mode 100644 index 0000000..e6d11d9 --- /dev/null +++ b/dwg-rooms/main.py @@ -0,0 +1,143 @@ +"""FastAPI app: upload DWG/DXF → extract rooms → export Excel.""" +import json +import logging +import os +import subprocess +import uuid +from pathlib import Path + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from excel_export import export_to_excel +from extractor import DEFAULT_EXAMPLES, extract_rooms +from llm_helper import enhance_with_llm + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="DWG Room Extractor") +app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) + +WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/dwg-rooms")) +WORK_DIR.mkdir(parents=True, exist_ok=True) + +jobs: dict[str, dict] = {} + + +def _dwg_to_dxf(dwg_path: Path, out_dir: Path) -> Path: + """Convert DWG → DXF using LibreDWG dwgread. Returns DXF path.""" + dxf_path = out_dir / f"{dwg_path.stem}.dxf" + r = subprocess.run( + ["dwgread", "-O", "DXF", "-o", str(dxf_path), str(dwg_path)], + capture_output=True, + text=True, + timeout=120, + ) + if not dxf_path.exists(): + raise RuntimeError( + f"DWG conversion failed (exit {r.returncode}): {r.stderr or r.stdout or 'no output'}" + ) + return dxf_path + + +@app.get("/") +async def root(): + return FileResponse("static/index.html") + + +@app.get("/api/defaults") +async def defaults(): + return {"examples": DEFAULT_EXAMPLES} + + +@app.post("/api/upload") +async def upload( + file: UploadFile = File(...), + examples: str = Form(default=""), # JSON array of strings +): + suffix = Path(file.filename or "input.dxf").suffix.lower() + if suffix not in (".dwg", ".dxf"): + raise HTTPException(400, "Supported formats: .dwg, .dxf") + + try: + ex_list = json.loads(examples) if examples else None + if ex_list is not None and not isinstance(ex_list, list): + ex_list = None + except json.JSONDecodeError: + ex_list = None + + job_id = str(uuid.uuid4()) + job_dir = WORK_DIR / job_id + job_dir.mkdir() + + input_path = job_dir / f"input{suffix}" + content = await file.read() + input_path.write_bytes(content) + logger.info("Job %s: %s (%d bytes), examples=%r", job_id, file.filename, len(content), ex_list) + + steps: list[str] = [] + + try: + if suffix == ".dwg": + steps.append("Konverze DWG → DXF") + dxf_path = _dwg_to_dxf(input_path, job_dir) + else: + dxf_path = input_path + + steps.append("Extrakce místností (pravidla)") + rooms, unmatched = extract_rooms(str(dxf_path), examples=ex_list) + logger.info("Job %s: %d rooms, %d unmatched texts", job_id, len(rooms), len(unmatched)) + + if unmatched and os.getenv("LITELLM_API_KEY", ""): + steps.append("AI rozšíření") + llm_rooms = await enhance_with_llm(unmatched) + logger.info("Job %s: LLM added %d rooms", job_id, len(llm_rooms)) + rooms.extend(llm_rooms) + + jobs[job_id] = {"filename": file.filename, "rooms": rooms} + return {"job_id": job_id, "room_count": len(rooms), "steps": steps} + + except Exception as exc: + logger.error("Job %s failed: %s", job_id, exc) + raise HTTPException(500, str(exc)) + + +@app.get("/api/rooms/{job_id}") +async def get_rooms(job_id: str): + if job_id not in jobs: + raise HTTPException(404, "Not found") + return jobs[job_id]["rooms"] + + +@app.put("/api/rooms/{job_id}") +async def update_rooms(job_id: str, rooms: list[dict]): + if job_id not in jobs: + raise HTTPException(404, "Not found") + jobs[job_id]["rooms"] = rooms + return {"ok": True, "count": len(rooms)} + + +@app.get("/api/export/{job_id}") +async def export(job_id: str): + if job_id not in jobs: + raise HTTPException(404, "Not found") + job = jobs[job_id] + excel_path = WORK_DIR / job_id / "rooms.xlsx" + export_to_excel(job["rooms"], str(excel_path)) + stem = Path(job["filename"]).stem + return FileResponse( + str(excel_path), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + filename=f"mistnosti_{stem}.xlsx", + ) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/dwg-rooms/requirements.txt b/dwg-rooms/requirements.txt new file mode 100644 index 0000000..ce67a58 --- /dev/null +++ b/dwg-rooms/requirements.txt @@ -0,0 +1,9 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +ezdxf>=1.3 +openpyxl>=3.1 +pandas>=2.2 +python-multipart>=0.0.9 +openai>=1.50 +python-dotenv>=1.0 +aiofiles>=23.0 diff --git a/dwg-rooms/static/app.js b/dwg-rooms/static/app.js new file mode 100644 index 0000000..86edcd3 --- /dev/null +++ b/dwg-rooms/static/app.js @@ -0,0 +1,209 @@ +"use strict"; + +const $ = id => document.getElementById(id); + +const sUpload = $("s-upload"); +const sProcessing = $("s-processing"); +const sResults = $("s-results"); +const dropZone = $("drop-zone"); +const fileInput = $("file-input"); +const browseBtn = $("browse-btn"); +const stepsList = $("steps-list"); +const roomsTbody = $("rooms-tbody"); +const resultsMeta = $("results-meta"); +const exportBtn = $("export-btn"); +const resetBtn = $("reset-btn"); +const examplesList = $("examples-list"); +const examplesForm = $("examples-form"); +const exampleInput = $("example-input"); + +let jobId = null; +let rooms = []; +let examples = []; + +// ── Examples management ─────────────────────────── +async function loadDefaults() { + try { + const resp = await fetch("/api/defaults"); + const data = await resp.json(); + examples = Array.isArray(data.examples) ? data.examples : []; + renderExamples(); + } catch (e) { + console.warn("Could not load defaults:", e); + } +} + +function renderExamples() { + examplesList.innerHTML = ""; + examples.forEach((ex, i) => { + const chip = document.createElement("span"); + chip.className = "example-chip"; + chip.innerHTML = ` + ${esc(ex)} + + `; + examplesList.appendChild(chip); + }); + examplesList.querySelectorAll(".example-chip-remove").forEach(btn => { + btn.addEventListener("click", () => { + examples.splice(parseInt(btn.dataset.i, 10), 1); + renderExamples(); + }); + }); +} + +examplesForm.addEventListener("submit", e => { + e.preventDefault(); + const v = exampleInput.value.trim(); + if (!v) return; + if (!/\d/.test(v)) { + exampleInput.focus(); + exampleInput.select(); + return; + } + if (!examples.includes(v)) examples.push(v); + exampleInput.value = ""; + renderExamples(); + exampleInput.focus(); +}); + +// ── Drag & drop ─────────────────────────────────── +dropZone.addEventListener("dragover", e => { e.preventDefault(); dropZone.classList.add("drag-over"); }); +dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over")); +dropZone.addEventListener("drop", e => { + e.preventDefault(); + dropZone.classList.remove("drag-over"); + const f = e.dataTransfer.files[0]; + if (f) handleFile(f); +}); +dropZone.addEventListener("click", () => fileInput.click()); +browseBtn.addEventListener("click", e => { e.stopPropagation(); fileInput.click(); }); +fileInput.addEventListener("change", e => { if (e.target.files[0]) handleFile(e.target.files[0]); }); + +// ── Upload & process ────────────────────────────── +async function handleFile(file) { + const ext = file.name.split(".").pop().toLowerCase(); + if (!["dwg", "dxf"].includes(ext)) { + alert("Podporované formáty jsou .dwg a .dxf"); + return; + } + + show(sProcessing); hide(sUpload); hide(sResults); + stepsList.innerHTML = ""; + addStep("Nahrávání souboru…", "active"); + + const fd = new FormData(); + fd.append("file", file); + fd.append("examples", JSON.stringify(examples)); + + try { + const resp = await fetch("/api/upload", { method: "POST", body: fd }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.detail || `HTTP ${resp.status}`); + } + const data = await resp.json(); + jobId = data.job_id; + + stepsList.innerHTML = ""; + for (const step of data.steps) addStep(step, "done"); + + const rResp = await fetch(`/api/rooms/${jobId}`); + rooms = await rResp.json(); + renderResults(); + } catch (err) { + stepsList.innerHTML = ""; + addStep(`Chyba: ${err.message}`, "error"); + setTimeout(() => { show(sUpload); hide(sProcessing); }, 4000); + } +} + +function addStep(label, state) { + const el = document.createElement("div"); + el.className = `step-item ${state}`; + el.innerHTML = `${esc(label)}`; + stepsList.appendChild(el); +} + +// ── Render table ────────────────────────────────── +function renderResults() { + hide(sProcessing); show(sResults); + + const total = rooms.length; + const llmCount = rooms.filter(r => r.source === "llm").length; + resultsMeta.textContent = `${total} místností nalezeno${llmCount ? ` (${llmCount} z AI)` : ""}`; + + roomsTbody.innerHTML = ""; + const sorted = [...rooms].sort((a, b) => String(a.room).localeCompare(String(b.room))); + + sorted.forEach((room, i) => { + const tr = document.createElement("tr"); + const pct = Math.round((room.confidence ?? 1) * 100); + const badgeCls = room.source === "llm" ? "badge-llm" : "badge-rule"; + const badgeTxt = room.source === "llm" ? "AI" : "Pravidla"; + + tr.innerHTML = ` + ${esc(room.room)} + ${esc(room.description)} + ${badgeTxt} + ${pct} % + `; + + tr.querySelectorAll("[contenteditable]").forEach(cell => { + cell.addEventListener("blur", () => { + const idx = parseInt(cell.dataset.i, 10); + const field = cell.dataset.field; + rooms[idx][field] = cell.textContent.trim(); + }); + cell.addEventListener("keydown", e => { + if (e.key === "Enter") { e.preventDefault(); cell.blur(); } + }); + }); + + roomsTbody.appendChild(tr); + }); +} + +// ── Export ──────────────────────────────────────── +exportBtn.addEventListener("click", async () => { + exportBtn.disabled = true; + exportBtn.textContent = "Ukládám…"; + try { + await fetch(`/api/rooms/${jobId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(rooms), + }); + window.location.href = `/api/export/${jobId}`; + } finally { + setTimeout(() => { + exportBtn.disabled = false; + exportBtn.innerHTML = ` + + Exportovat Excel`; + }, 1500); + } +}); + +// ── Reset ───────────────────────────────────────── +resetBtn.addEventListener("click", () => { + jobId = null; rooms = []; + fileInput.value = ""; + stepsList.innerHTML = ""; roomsTbody.innerHTML = ""; + show(sUpload); hide(sResults); hide(sProcessing); +}); + +// ── Helpers ─────────────────────────────────────── +function show(el) { el.classList.remove("hidden"); } +function hide(el) { el.classList.add("hidden"); } +function esc(s) { + return String(s ?? "") + .replace(/&/g, "&").replace(//g, ">"); +} + +// ── Init ────────────────────────────────────────── +loadDefaults(); diff --git a/dwg-rooms/static/index.html b/dwg-rooms/static/index.html new file mode 100644 index 0000000..46f6858 --- /dev/null +++ b/dwg-rooms/static/index.html @@ -0,0 +1,161 @@ + + + + + + Extrakce místností z DWG | Colsys AI + + + + + + + + + +
+
+ + C + Colsys AI + + Extrakce místností z DWG / DXF + + + Zpět na portál + +
+
+ +
+ + +
+
+

Extrakce místností z výkresu

+

+ Nahrajte výkres ve formátu DWG nebo DXF. + Aplikace automaticky rozpozná čísla a názvy místností a připraví + editovatelnou tabulku pro export do Excelu. +

+
+ + +
+
+ Vzorová čísla místností + Zadejte konkrétní příklad čísla z výkresu — cifry se nahradí libovolnými, ostatní znaky zůstanou doslovně. +
+ +
+ +
+ + +
+
+ +
+ + + +

Přetáhněte soubor DWG nebo DXF sem

+

nebo

+ +

Podporované formáty: .dwg  ·  .dxf

+ +
+
+ + + + + + + +
+ + + + diff --git a/dwg-rooms/static/styles.css b/dwg-rooms/static/styles.css new file mode 100644 index 0000000..cb573e7 --- /dev/null +++ b/dwg-rooms/static/styles.css @@ -0,0 +1,459 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f2f4f7; + --text-primary: #101828; + --text-secondary: #354052; + --text-tertiary: #676f83; + --text-quaternary: #98a2b2; + --border-default: rgb(16 24 40 / 0.08); + --border-strong: #d0d5dc; + --border-subtle: rgb(16 24 40 / 0.04); + --card: #ffffff; + --primary: #155aef; + --primary-hover: #004aeb; + --accent-indigo: #444ce7; + --shadow-card: 0 1px 2px rgb(16 24 40 / 0.05); + --radius-md: 8px; + --radius-lg: 12px; +} + +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; + } +} +:root.dark { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; +} + +body { + font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 100vh; + font-size: 14px; + line-height: 1.5; +} + +/* ── Header ─────────────────────────────────────── */ +.header { + position: sticky; + top: 0; + z-index: 30; + border-bottom: 0.5px solid var(--border-default); + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + backdrop-filter: blur(8px); +} +.header-inner { + max-width: 1280px; + margin: 0 auto; + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; + gap: 16px; +} +@media (min-width: 640px) { + .header-inner { padding: 0 32px; } +} +.brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} +.brand-icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%); + box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 12px; + letter-spacing: -0.04em; + color: white; +} +.brand-name { + font-size: 14px; + font-weight: 600; + letter-spacing: -0.025em; + color: var(--text-primary); +} +.brand-ai { color: var(--primary); } +.header-crumb { + font-size: 13px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Main ────────────────────────────────────────── */ +.main { + max-width: 900px; + margin: 0 auto; + padding: 40px 24px 80px; + display: flex; + flex-direction: column; + gap: 32px; +} +@media (max-width: 640px) { + .header-inner { padding: 0 16px; } + .main { padding: 24px 16px 60px; } +} + +/* ── Section intro ───────────────────────────────── */ +.section-intro { margin-bottom: 24px; } +.section-title { + font-size: 22px; + font-weight: 600; + letter-spacing: -0.02em; + margin-bottom: 8px; +} +.section-desc { + color: var(--text-tertiary); + max-width: 580px; + font-size: 14px; + line-height: 1.6; +} + +/* ── Examples panel ──────────────────────────────── */ +.examples-panel { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--card); + box-shadow: var(--shadow-card); + padding: 16px; + margin-bottom: 16px; +} +.examples-header { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 12px; +} +.examples-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} +.examples-subtitle { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; +} +.examples-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 28px; + margin-bottom: 10px; +} +.examples-list:empty::before { + content: "Zatím žádné vzory — přidejte alespoň jeden níže"; + font-size: 12px; + color: var(--text-quaternary); + font-style: italic; + padding: 6px 0; +} +.example-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent); + color: var(--text-primary); + padding: 4px 4px 4px 10px; + border-radius: 999px; + font-size: 12px; + font-family: ui-monospace, monospace; +} +.example-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-tertiary); + padding: 0; + transition: background 0.1s, color 0.1s; +} +.example-chip-remove:hover { + background: color-mix(in srgb, var(--primary) 18%, transparent); + color: var(--text-primary); +} +.example-chip-remove svg { + width: 12px; + height: 12px; +} +.examples-input-row { + display: flex; + gap: 8px; +} +.example-input { + flex: 1; + font-family: ui-monospace, monospace; + font-size: 13px; + padding: 8px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-primary); + min-width: 0; +} +.example-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} +.example-add-btn { + flex-shrink: 0; +} + +/* ── Drop zone ───────────────────────────────────── */ +.drop-zone { + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--card); + padding: 56px 32px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + box-shadow: var(--shadow-card); +} +.drop-zone:hover, .drop-zone.drag-over { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 4%, var(--card)); +} +.drop-icon { + width: 44px; + height: 44px; + color: var(--text-quaternary); + margin: 0 auto 18px; + display: block; + transition: color 0.15s; +} +.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon { + color: var(--primary); +} +.drop-text { + font-size: 15px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} +.drop-or { + font-size: 13px; + color: var(--text-quaternary); + margin-bottom: 14px; +} +.drop-formats { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 14px; +} + +/* ── Buttons ─────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.15s, box-shadow 0.15s; + line-height: 1; +} +.btn-primary { + background: var(--primary); + color: #fff; + box-shadow: 0 1px 2px rgb(21 90 239 / 0.25); +} +.btn-primary:hover { background: var(--primary-hover); } +.btn-primary:disabled { opacity: 0.5; cursor: default; } +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-strong); +} +.btn-secondary:hover { background: var(--border-strong); } +.btn-icon { width: 15px; height: 15px; flex-shrink: 0; } + +/* ── Processing card ─────────────────────────────── */ +.processing-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 56px 32px; + text-align: center; + box-shadow: var(--shadow-card); +} +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border-strong); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin: 0 auto 22px; +} +@keyframes spin { to { transform: rotate(360deg); } } +.processing-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 20px; + color: var(--text-primary); +} +.steps-list { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; + margin: 0 auto; + text-align: left; +} +.step-item { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text-tertiary); + padding: 8px 12px; + border-radius: var(--radius-md); + background: var(--bg-tertiary); +} +.step-item.active { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); +} +.step-item.done { color: #17b26a; } +.step-item.error { color: #d92d20; background: #fef3f2; } +.step-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +/* ── Results ─────────────────────────────────────── */ +.results-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.results-title { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.015em; + margin-bottom: 4px; +} +.results-meta { + font-size: 13px; + color: var(--text-tertiary); +} +.results-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ── Table ───────────────────────────────────────── */ +.table-wrap { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-card); + background: var(--card); + overflow-x: auto; +} +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + min-width: 500px; +} +thead { + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-default); +} +th { + padding: 10px 14px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + white-space: nowrap; +} +td { + padding: 8px 14px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-primary); + vertical-align: middle; +} +tr:last-child td { border-bottom: none; } +tr:nth-child(even) td { background: var(--bg-secondary); } +td[contenteditable]:focus { + outline: none; + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); + box-shadow: inset 0 0 0 1.5px var(--primary); + border-radius: 3px; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 11px; + font-weight: 500; +} +.badge-rule { background: #d1e0ff; color: #004aeb; } +.badge-llm { background: #d1fae5; color: #065f46; } +@media (prefers-color-scheme: dark) { + .badge-rule { background: #1e3a8a; color: #93c5fd; } + .badge-llm { background: #064e3b; color: #6ee7b7; } +} + +.table-hint { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 8px; +} + +.hidden { display: none !important; } diff --git a/email-drafter/.env.example b/email-drafter/.env.example new file mode 100644 index 0000000..1bdec6f --- /dev/null +++ b/email-drafter/.env.example @@ -0,0 +1,3 @@ +LITELLM_BASE_URL=http://host.docker.internal:4000/v1 +LITELLM_API_KEY=sk-... +LLM_MODEL=anthropic/claude-sonnet-4-20250514 diff --git a/email-drafter/Dockerfile b/email-drafter/Dockerfile new file mode 100644 index 0000000..b3a66cf --- /dev/null +++ b/email-drafter/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/email-drafter/docker-compose.yml b/email-drafter/docker-compose.yml new file mode 100644 index 0000000..f08e387 --- /dev/null +++ b/email-drafter/docker-compose.yml @@ -0,0 +1,19 @@ +services: + email-drafter: + build: . + container_name: email-drafter + restart: unless-stopped + ports: + - "127.0.0.1:3028:8000" + environment: + LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://host.docker.internal:4000/v1} + LITELLM_API_KEY: ${LITELLM_API_KEY:-} + LLM_MODEL: ${LLM_MODEL:-anthropic/claude-sonnet-4-20250514} + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - localai + +networks: + localai: + external: true diff --git a/email-drafter/main.py b/email-drafter/main.py new file mode 100644 index 0000000..6369f3d --- /dev/null +++ b/email-drafter/main.py @@ -0,0 +1,150 @@ +"""FastAPI: bullet notes → polished business email via Claude Sonnet 4.""" +import json +import logging +import os +from typing import Literal + +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from openai import AsyncOpenAI +from pydantic import BaseModel, Field + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="Email Drafter") +app.add_middleware(CORSMiddleware, allow_origins=["*"], + allow_methods=["*"], allow_headers=["*"]) + +MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514") +_client: AsyncOpenAI | None = None + + +def _get_client() -> AsyncOpenAI: + global _client + if _client is None: + _client = AsyncOpenAI( + base_url=os.getenv("LITELLM_BASE_URL", "http://host.docker.internal:4000/v1"), + api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"), + ) + return _client + + +class GenerateRequest(BaseModel): + notes: str = Field(..., min_length=1, max_length=4000) + recipient: str = "" + signature: str = "" + tone: Literal["formal", "friendly", "firm", "apologetic"] = "formal" + language: Literal["cs", "en"] = "cs" + reply_to: str = "" # Optional original email being replied to + + +TONE_HINTS = { + "formal": {"cs": "formální, oficiální, profesionální", + "en": "formal, professional, polite"}, + "friendly": {"cs": "přátelský, vstřícný, kolegiální", + "en": "friendly, warm, collegial"}, + "firm": {"cs": "důrazný, asertivní, věcný, ale zdvořilý", + "en": "firm, assertive, direct yet polite"}, + "apologetic": {"cs": "omluvný, smířlivý, akceptující odpovědnost", + "en": "apologetic, conciliatory, owning responsibility"}, +} + + +def _system_prompt(language: str, tone: str) -> str: + tone_hint = TONE_HINTS[tone][language] + if language == "cs": + return f"""Jste asistent píšící obchodní e-maily v češtině. + +Vstupem jsou poznámky / odrážky od pisatele. Z nich vytvoříte uhlazený, profesionální e-mail. + +Tón: {tone_hint}. + +Vraťte POUZE platný JSON v tomto tvaru, žádné markdown obaly: +{{ + "subject": "Předmět e-mailu (krátký, výstižný, do 70 znaků)", + "body": "Tělo e-mailu včetně oslovení na začátku a podpisu na konci. Použijte odstavce oddělené prázdným řádkem." +}} + +Pravidla: +- Pište v 1. osobě jednotného čísla (jako pisatel sám) +- Oslovení voľte podle kontextu příjemce (Vážený pane / Ahoj / atd.) +- Pokud vstup obsahuje žádost, zformulujte ji jasně a zdvořile +- Nevymýšlejte si fakta, která nejsou ve vstupu +- Pokud je dodán podpis, použijte ho doslovně. Jinak ukončete obecným podpisem typu „S pozdravem,\\nVaše jméno" +- Pokud je dodán původní e-mail (reply_to), navažte na něj — odkažte na to, co píše""" + else: + return f"""You are an assistant writing business emails in English. + +The input is bullet notes from the writer. Turn them into a polished, professional email. + +Tone: {tone_hint}. + +Return ONLY valid JSON in this shape, no markdown wrappers: +{{ + "subject": "Email subject (short, to the point, under 70 chars)", + "body": "Email body including greeting at top and signature at end. Use paragraphs separated by blank lines." +}} + +Rules: +- Write in first person (as the sender) +- Choose greeting based on recipient context (Dear / Hi / Hello) +- If input contains a request, phrase it clearly and politely +- Do not invent facts not in the input +- If a signature is provided, use it verbatim. Otherwise end with a generic "Best regards,\\nYour name" +- If an original email is provided (reply_to), reference it appropriately""" + + +@app.get("/") +async def root(): + return FileResponse("static/index.html") + + +@app.post("/api/generate") +async def generate(req: GenerateRequest): + user_parts = [f"POZNÁMKY PISATELE / WRITER NOTES:\n{req.notes.strip()}"] + if req.recipient.strip(): + user_parts.append(f"\nPŘÍJEMCE / RECIPIENT:\n{req.recipient.strip()}") + if req.signature.strip(): + user_parts.append(f"\nPODPIS / SIGNATURE:\n{req.signature.strip()}") + if req.reply_to.strip(): + user_parts.append( + f"\nPŮVODNÍ E-MAIL (pro reply) / ORIGINAL EMAIL TO REPLY TO:\n{req.reply_to.strip()}" + ) + + try: + resp = await _get_client().chat.completions.create( + model=MODEL, + messages=[ + {"role": "system", "content": _system_prompt(req.language, req.tone)}, + {"role": "user", "content": "\n".join(user_parts)}, + ], + temperature=0.3, + max_tokens=2000, + ) + except Exception as exc: + logger.exception("LLM call failed") + raise HTTPException(500, f"Generování selhalo: {exc}") + + raw = (resp.choices[0].message.content or "").strip() + raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + logger.error("JSON parse failed: %s\nraw=%s", exc, raw[:500]) + raise HTTPException(500, "Nepodařilo se zpracovat odpověď AI") + + return { + "subject": data.get("subject", "").strip(), + "body": data.get("body", "").strip(), + } + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/email-drafter/requirements.txt b/email-drafter/requirements.txt new file mode 100644 index 0000000..d5cbd9d --- /dev/null +++ b/email-drafter/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +openai>=1.50 +python-dotenv>=1.0 +pydantic>=2.0 diff --git a/email-drafter/static/app.js b/email-drafter/static/app.js new file mode 100644 index 0000000..882f1d3 --- /dev/null +++ b/email-drafter/static/app.js @@ -0,0 +1,120 @@ +// Email drafter frontend +(() => { + const $ = (id) => document.getElementById(id); + const sections = { + compose: $("s-compose"), + processing: $("s-processing"), + result: $("s-result"), + }; + const show = (name) => { + for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name); + }; + + // ── Persist signature & last-used tone/language in localStorage ── + const PERSIST_KEYS = ["signature", "tone", "language"]; + for (const k of PERSIST_KEYS) { + const v = localStorage.getItem("email_" + k); + if (v !== null) $(k).value = v; + } + for (const k of PERSIST_KEYS) { + $(k).addEventListener("change", () => + localStorage.setItem("email_" + k, $(k).value)); + $(k).addEventListener("input", () => + localStorage.setItem("email_" + k, $(k).value)); + } + + // ── Reply toggle ── + $("reply-toggle").addEventListener("click", () => { + const t = $("reply-toggle"); + const p = $("reply-panel"); + const expanded = t.getAttribute("aria-expanded") === "true"; + t.setAttribute("aria-expanded", String(!expanded)); + p.classList.toggle("hidden", expanded); + }); + + // ── Hint + button state ── + function updateHint() { + const notes = $("notes").value.trim(); + const btn = $("generate-btn"); + const hint = $("generate-hint"); + if (notes.length < 5) { + btn.disabled = true; + hint.textContent = "Zadejte alespoň krátké poznámky."; + } else { + btn.disabled = false; + hint.textContent = `${notes.length} znaků zadáno. Klikněte pro vygenerování.`; + } + } + $("notes").addEventListener("input", updateHint); + updateHint(); + + // ── Generate ── + async function generate() { + const payload = { + notes: $("notes").value.trim(), + recipient: $("recipient").value.trim(), + signature: $("signature").value.trim(), + tone: $("tone").value, + language: $("language").value, + reply_to: $("reply-to").value.trim(), + }; + if (payload.notes.length < 5) { + alert("Zadejte alespoň krátké poznámky."); + return; + } + show("processing"); + try { + const r = await fetch("/api/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!r.ok) { + const errBody = await r.json().catch(() => ({ detail: r.statusText })); + throw new Error(errBody.detail || r.statusText); + } + const data = await r.json(); + $("out-subject").value = data.subject || ""; + $("out-body").value = data.body || ""; + // Auto-resize body textarea to fit content + const ta = $("out-body"); + ta.style.height = "auto"; + ta.style.height = Math.max(280, ta.scrollHeight + 4) + "px"; + show("result"); + } catch (err) { + alert("Chyba: " + err.message); + show("compose"); + } + } + + $("generate-btn").addEventListener("click", generate); + $("regenerate-btn").addEventListener("click", generate); + $("back-btn").addEventListener("click", () => show("compose")); + + // ── Copy buttons ── + function copyText(text, btn) { + navigator.clipboard.writeText(text).then(() => { + const original = btn.textContent; + btn.textContent = "Zkopírováno"; + btn.classList.add("copied"); + setTimeout(() => { + btn.textContent = original; + btn.classList.remove("copied"); + }, 1500); + }).catch(() => alert("Kopírování selhalo. Vyberte text a stiskněte Ctrl+C.")); + } + + document.querySelectorAll(".btn-copy").forEach((btn) => { + btn.addEventListener("click", () => { + const target = $(btn.dataset.target); + copyText(target.value, btn); + }); + }); + + $("copy-all-btn").addEventListener("click", (e) => { + const subject = $("out-subject").value; + const body = $("out-body").value; + const combined = `Předmět: ${subject}\n\n${body}`; + copyText(combined, e.target); + }); +})(); diff --git a/email-drafter/static/extra.css b/email-drafter/static/extra.css new file mode 100644 index 0000000..ecfcf18 --- /dev/null +++ b/email-drafter/static/extra.css @@ -0,0 +1,201 @@ +/* Email-drafter specific styles */ + +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px 18px; + margin-bottom: 24px; +} +.form-row { display: flex; flex-direction: column; gap: 6px; } +.form-row-full { grid-column: 1 / -1; } +@media (max-width: 720px) { + .form-grid { grid-template-columns: 1fr; } +} + +.form-label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} +.form-label-hint { + font-size: 11px; + font-weight: 400; + color: var(--text-quaternary); + text-transform: lowercase; +} + +.form-input, .form-select, .form-textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-default); + background: var(--bg-primary); + color: var(--text-primary); + border-radius: 8px; + font-size: 14px; + font-family: inherit; + line-height: 1.5; + transition: border-color .15s, box-shadow .15s; +} +.form-input:focus, .form-select:focus, .form-textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} +.form-textarea { resize: vertical; min-height: 120px; } +.form-select { cursor: pointer; } + +.reply-toggle { + background: transparent; + border: 1px dashed var(--border-strong); + color: var(--text-secondary); + padding: 10px 14px; + border-radius: 8px; + cursor: pointer; + font-size: 13px; + font-family: inherit; + text-align: left; + width: fit-content; + display: flex; + align-items: center; + gap: 8px; + transition: border-color .15s, color .15s; +} +.reply-toggle:hover { border-color: var(--primary); color: var(--primary); } +.reply-toggle[aria-expanded="true"] .reply-toggle-icon { transform: rotate(45deg); } +.reply-toggle-icon { + display: inline-block; + font-size: 16px; + line-height: 1; + transition: transform .2s; +} +.reply-panel { margin-top: 10px; } + +.run-row { + display: flex; + align-items: center; + gap: 14px; + padding-top: 16px; + border-top: 1px solid var(--border-default); + flex-wrap: wrap; +} +.btn-lg { padding: 12px 24px; font-size: 14px; font-weight: 600; } +.run-hint { font-size: 13px; color: var(--text-tertiary); } + +.processing-sub { + font-size: 13px; + color: var(--text-tertiary); + margin: 8px auto 0; + max-width: 360px; +} + +/* Output email card */ +.email-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 18px 20px; + margin-top: 16px; + box-shadow: var(--shadow-card); +} +.email-field { display: flex; flex-direction: column; gap: 8px; } +.email-field-row { + display: flex; + justify-content: space-between; + align-items: center; +} +.email-field-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-tertiary); +} +.email-subject-input { + width: 100%; + padding: 10px 12px; + border: 1px solid transparent; + background: var(--bg-secondary); + color: var(--text-primary); + border-radius: 8px; + font-size: 15px; + font-weight: 600; + font-family: inherit; +} +.email-subject-input:focus { + outline: none; + border-color: var(--primary); + background: var(--bg-primary); +} +.email-body-textarea { + width: 100%; + padding: 14px 16px; + border: 1px solid transparent; + background: var(--bg-secondary); + color: var(--text-primary); + border-radius: 8px; + font-size: 14px; + font-family: inherit; + line-height: 1.6; + resize: vertical; + min-height: 280px; + white-space: pre-wrap; +} +.email-body-textarea:focus { + outline: none; + border-color: var(--primary); + background: var(--bg-primary); +} +.email-divider { + height: 1px; + background: var(--border-default); + margin: 16px 0; +} +.email-actions { + display: flex; + justify-content: flex-end; + margin-top: 16px; +} + +.btn-copy { + background: transparent; + border: 1px solid var(--border-default); + color: var(--text-secondary); + padding: 4px 10px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + font-family: inherit; + transition: all .15s; +} +.btn-copy:hover { + border-color: var(--primary); + color: var(--primary); +} +.btn-copy.copied { + background: rgba(34, 197, 94, 0.12); + border-color: #16a34a; + color: #15803d; +} + +/* Back link (shared with other apps) */ +.back-link { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 12px 6px 10px; border-radius: 8px; + font-size: 13px; font-weight: 500; color: var(--text-tertiary); + text-decoration: none; border: 0.5px solid var(--border-default); + background: var(--bg-primary); flex-shrink: 0; + transition: color .15s, border-color .15s, background .15s; +} +.back-link:hover { + color: var(--primary); border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); +} +.back-link svg { opacity: 0.8; } +@media (max-width: 640px) { + .back-link span { display: none; } + .back-link { padding: 6px; } +} diff --git a/email-drafter/static/index.html b/email-drafter/static/index.html new file mode 100644 index 0000000..8f60b58 --- /dev/null +++ b/email-drafter/static/index.html @@ -0,0 +1,181 @@ + + + + + + Návrh e-mailu | Colsys AI + + + + + + + + + +
+ +
+ +
+ +
+
+

Návrh e-mailu z poznámek

+

+ Zadejte odrážky toho, co chcete napsat. AI z toho udělá uhlazený + profesionální e-mail. +

+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + Zadejte alespoň krátké poznámky. +
+
+ + + + + +
+ + + + diff --git a/email-drafter/static/styles.css b/email-drafter/static/styles.css new file mode 100644 index 0000000..9efda74 --- /dev/null +++ b/email-drafter/static/styles.css @@ -0,0 +1,461 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f2f4f7; + --text-primary: #101828; + --text-secondary: #354052; + --text-tertiary: #676f83; + --text-quaternary: #98a2b2; + --border-default: rgb(16 24 40 / 0.08); + --border-strong: #d0d5dc; + --border-subtle: rgb(16 24 40 / 0.04); + --card: #ffffff; + --primary: #155aef; + --primary-hover: #004aeb; + --accent-indigo: #444ce7; + --shadow-card: 0 1px 2px rgb(16 24 40 / 0.05); + --radius-md: 8px; + --radius-lg: 12px; +} + +/* Dark theme — applies when (a) user OS prefers dark and no .light override, + or (b) :root has explicit .dark class (set by portal_theme cookie). */ +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; + } +} +:root.dark { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; +} + +body { + font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 100vh; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Header ─────────────────────────────────────── */ +.header { + position: sticky; + top: 0; + z-index: 30; + border-bottom: 0.5px solid var(--border-default); + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + backdrop-filter: blur(8px); +} +.header-inner { + max-width: 1280px; /* match portal max-w-7xl so brand doesn't shift */ + margin: 0 auto; + height: 56px; /* portal uses h-14 */ + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; /* portal px-4 */ + gap: 16px; +} +@media (min-width: 640px) { + .header-inner { padding: 0 32px; } /* portal sm:px-8 */ +} +.brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} +.brand-icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%); + box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 12px; + letter-spacing: -0.04em; + color: white; +} +.brand-name { + font-size: 14px; /* tailwind text-sm */ + font-weight: 600; + letter-spacing: -0.025em; /* tailwind tracking-tight */ + color: var(--text-primary); +} +.brand-ai { color: var(--primary); } +.header-crumb { + font-size: 13px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Main ────────────────────────────────────────── */ +.main { + max-width: 900px; + margin: 0 auto; + padding: 40px 24px 80px; + display: flex; + flex-direction: column; + gap: 32px; +} +@media (max-width: 640px) { + .header-inner { padding: 0 16px; } + .main { padding: 24px 16px 60px; } +} + +/* ── Section intro ───────────────────────────────── */ +.section-intro { margin-bottom: 24px; } +.section-title { + font-size: 22px; + font-weight: 600; + letter-spacing: -0.02em; + margin-bottom: 8px; +} +.section-desc { + color: var(--text-tertiary); + max-width: 580px; + font-size: 14px; + line-height: 1.6; +} + +/* ── Examples panel ──────────────────────────────── */ +.examples-panel { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--card); + box-shadow: var(--shadow-card); + padding: 16px; + margin-bottom: 16px; +} +.examples-header { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 12px; +} +.examples-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} +.examples-subtitle { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; +} +.examples-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 28px; + margin-bottom: 10px; +} +.examples-list:empty::before { + content: "Zatím žádné vzory — přidejte alespoň jeden níže"; + font-size: 12px; + color: var(--text-quaternary); + font-style: italic; + padding: 6px 0; +} +.example-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent); + color: var(--text-primary); + padding: 4px 4px 4px 10px; + border-radius: 999px; + font-size: 12px; + font-family: ui-monospace, monospace; +} +.example-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-tertiary); + padding: 0; + transition: background 0.1s, color 0.1s; +} +.example-chip-remove:hover { + background: color-mix(in srgb, var(--primary) 18%, transparent); + color: var(--text-primary); +} +.example-chip-remove svg { + width: 12px; + height: 12px; +} +.examples-input-row { + display: flex; + gap: 8px; +} +.example-input { + flex: 1; + font-family: ui-monospace, monospace; + font-size: 13px; + padding: 8px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-primary); + min-width: 0; +} +.example-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} +.example-add-btn { + flex-shrink: 0; +} + +/* ── Drop zone ───────────────────────────────────── */ +.drop-zone { + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--card); + padding: 56px 32px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + box-shadow: var(--shadow-card); +} +.drop-zone:hover, .drop-zone.drag-over { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 4%, var(--card)); +} +.drop-icon { + width: 44px; + height: 44px; + color: var(--text-quaternary); + margin: 0 auto 18px; + display: block; + transition: color 0.15s; +} +.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon { + color: var(--primary); +} +.drop-text { + font-size: 15px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} +.drop-or { + font-size: 13px; + color: var(--text-quaternary); + margin-bottom: 14px; +} +.drop-formats { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 14px; +} + +/* ── Buttons ─────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.15s, box-shadow 0.15s; + line-height: 1; +} +.btn-primary { + background: var(--primary); + color: #fff; + box-shadow: 0 1px 2px rgb(21 90 239 / 0.25); +} +.btn-primary:hover { background: var(--primary-hover); } +.btn-primary:disabled { opacity: 0.5; cursor: default; } +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-strong); +} +.btn-secondary:hover { background: var(--border-strong); } +.btn-icon { width: 15px; height: 15px; flex-shrink: 0; } + +/* ── Processing card ─────────────────────────────── */ +.processing-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 56px 32px; + text-align: center; + box-shadow: var(--shadow-card); +} +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border-strong); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin: 0 auto 22px; +} +@keyframes spin { to { transform: rotate(360deg); } } +.processing-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 20px; + color: var(--text-primary); +} +.steps-list { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; + margin: 0 auto; + text-align: left; +} +.step-item { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text-tertiary); + padding: 8px 12px; + border-radius: var(--radius-md); + background: var(--bg-tertiary); +} +.step-item.active { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); +} +.step-item.done { color: #17b26a; } +.step-item.error { color: #d92d20; background: #fef3f2; } +.step-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +/* ── Results ─────────────────────────────────────── */ +.results-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.results-title { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.015em; + margin-bottom: 4px; +} +.results-meta { + font-size: 13px; + color: var(--text-tertiary); +} +.results-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ── Table ───────────────────────────────────────── */ +.table-wrap { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-card); + background: var(--card); + overflow-x: auto; +} +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + min-width: 500px; +} +thead { + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-default); +} +th { + padding: 10px 14px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + white-space: nowrap; +} +td { + padding: 8px 14px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-primary); + vertical-align: middle; +} +tr:last-child td { border-bottom: none; } +tr:nth-child(even) td { background: var(--bg-secondary); } +td[contenteditable]:focus { + outline: none; + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); + box-shadow: inset 0 0 0 1.5px var(--primary); + border-radius: 3px; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 11px; + font-weight: 500; +} +.badge-rule { background: #d1e0ff; color: #004aeb; } +.badge-llm { background: #d1fae5; color: #065f46; } +@media (prefers-color-scheme: dark) { + .badge-rule { background: #1e3a8a; color: #93c5fd; } + .badge-llm { background: #064e3b; color: #6ee7b7; } +} + +.table-hint { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 8px; +} + +.hidden { display: none !important; } diff --git a/feature-request/Dockerfile b/feature-request/Dockerfile new file mode 100644 index 0000000..f895222 --- /dev/null +++ b/feature-request/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /data/requests + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/feature-request/docker-compose.yml b/feature-request/docker-compose.yml new file mode 100644 index 0000000..3278725 --- /dev/null +++ b/feature-request/docker-compose.yml @@ -0,0 +1,18 @@ +services: + feature-request: + build: . + container_name: feature-request + restart: unless-stopped + ports: + - "127.0.0.1:3032:8000" + environment: + STORAGE_DIR: /data/requests + volumes: + # Bind mount on the host so requests are easy to inspect/review + - /home/klas/Prace/AI/portal/feature-request/data:/data/requests + networks: + - localai + +networks: + localai: + external: true diff --git a/feature-request/main.py b/feature-request/main.py new file mode 100644 index 0000000..299b60e --- /dev/null +++ b/feature-request/main.py @@ -0,0 +1,94 @@ +"""FastAPI: collect feature requests from portal users. + +Stores each request to a timestamped folder on disk so the team can review +later. No LLM, no external calls — pure form handler. +""" +import datetime +import json +import logging +import os +import re +import uuid +from pathlib import Path + +from fastapi import FastAPI, File, Form, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="Feature Request") +app.add_middleware(CORSMiddleware, allow_origins=["*"], + allow_methods=["*"], allow_headers=["*"]) + +# Volume-mounted; survives container rebuilds. +STORAGE = Path(os.getenv("STORAGE_DIR", "/data/requests")) +STORAGE.mkdir(parents=True, exist_ok=True) + +MAX_FILE_BYTES = 25 * 1024 * 1024 # 25 MB cap + + +def _slug(text: str) -> str: + text = re.sub(r"[^\w\s-]", "", text, flags=re.UNICODE).strip().lower() + text = re.sub(r"[\s_-]+", "-", text) + return text[:40] or "request" + + +@app.get("/") +async def root(): + return FileResponse("static/index.html") + + +@app.post("/api/submit") +async def submit( + title: str = Form(..., min_length=3, max_length=120), + description: str = Form(..., min_length=10, max_length=8000), + name: str = Form("", max_length=120), + email: str = Form("", max_length=200), + file: UploadFile | None = File(None), +): + title = title.strip() + description = description.strip() + if not title or not description: + raise HTTPException(400, "Vyplňte název a popis") + + now = datetime.datetime.now() + folder_name = f"{now.strftime('%Y%m%d_%H%M%S')}_{_slug(title)}_{uuid.uuid4().hex[:8]}" + folder = STORAGE / folder_name + folder.mkdir(parents=True, exist_ok=True) + + record = { + "submitted_at": now.isoformat(timespec="seconds"), + "title": title, + "description": description, + "name": name.strip(), + "email": email.strip(), + "attachment": None, + } + + if file is not None and file.filename: + raw = await file.read() + if len(raw) > MAX_FILE_BYTES: + raise HTTPException(400, f"Soubor je příliš velký (max {MAX_FILE_BYTES // (1024*1024)} MB)") + safe_name = re.sub(r"[^\w\.\-]", "_", file.filename)[:200] + attachment_path = folder / safe_name + attachment_path.write_bytes(raw) + record["attachment"] = safe_name + record["attachment_bytes"] = len(raw) + record["attachment_content_type"] = file.content_type or "" + + (folder / "request.json").write_text( + json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8" + ) + logger.info("Stored feature request → %s (%s)", folder_name, title) + return {"ok": True, "id": folder_name} + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/feature-request/requirements.txt b/feature-request/requirements.txt new file mode 100644 index 0000000..d6eee22 --- /dev/null +++ b/feature-request/requirements.txt @@ -0,0 +1,3 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +python-multipart>=0.0.9 diff --git a/feature-request/static/app.js b/feature-request/static/app.js new file mode 100644 index 0000000..5eaee04 --- /dev/null +++ b/feature-request/static/app.js @@ -0,0 +1,95 @@ +// Feature request form +(() => { + const $ = (id) => document.getElementById(id); + const sections = { + form: $("s-form"), + processing: $("s-processing"), + thanks: $("s-thanks"), + }; + const show = (name) => { + for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name); + }; + + // Persist name + email for next submission + for (const k of ["name", "email"]) { + const v = localStorage.getItem("req_" + k); + if (v) $(k).value = v; + $(k).addEventListener("input", () => localStorage.setItem("req_" + k, $(k).value)); + } + + // ── File handling ── + const fileInput = $("file-input"); + const dropZone = $("file-drop"); + const dropText = $("file-drop-text"); + let attachedFile = null; + + function setFile(f) { + if (!f) return; + attachedFile = f; + const size = f.size > 1024 * 1024 + ? `${(f.size / 1024 / 1024).toFixed(1)} MB` + : `${(f.size / 1024).toFixed(0)} kB`; + dropZone.classList.add("has-file"); + dropText.innerHTML = ` + + + + + + ${escapeHtml(f.name)} (${size}) + + + `; + $("file-clear").addEventListener("click", clearFile); + } + function clearFile() { + attachedFile = null; + fileInput.value = ""; + dropZone.classList.remove("has-file"); + dropText.innerHTML = `Přetáhněte soubor sem nebo + `; + $("file-pick-btn").addEventListener("click", () => fileInput.click()); + } + + $("file-pick-btn").addEventListener("click", () => fileInput.click()); + fileInput.addEventListener("change", (e) => setFile(e.target.files[0])); + + ["dragenter", "dragover"].forEach((ev) => + dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); })); + ["dragleave", "drop"].forEach((ev) => + dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); })); + dropZone.addEventListener("drop", (e) => { e.preventDefault(); setFile(e.dataTransfer.files[0]); }); + + // ── Submit ── + $("request-form").addEventListener("submit", async (e) => { + e.preventDefault(); + show("processing"); + try { + const fd = new FormData(); + fd.append("title", $("title").value); + fd.append("description", $("description").value); + fd.append("name", $("name").value); + fd.append("email", $("email").value); + if (attachedFile) fd.append("file", attachedFile); + + const r = await fetch("/api/submit", { method: "POST", body: fd }); + if (!r.ok) throw new Error((await r.json()).detail || r.statusText); + show("thanks"); + } catch (err) { + alert("Chyba: " + err.message); + show("form"); + } + }); + + $("another-btn").addEventListener("click", () => { + $("title").value = ""; + $("description").value = ""; + clearFile(); + show("form"); + }); + + function escapeHtml(s) { + return String(s).replace(/[&<>"']/g, (c) => + ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); + } +})(); diff --git a/feature-request/static/extra.css b/feature-request/static/extra.css new file mode 100644 index 0000000..bebbe1f --- /dev/null +++ b/feature-request/static/extra.css @@ -0,0 +1,164 @@ +/* Feature-request app styles */ + +.request-form { + display: flex; + flex-direction: column; + gap: 18px; +} +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} +@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } } + +.form-row { display: flex; flex-direction: column; gap: 6px; } +.form-label { + font-size: 13px; + font-weight: 500; + color: var(--text-secondary); + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 8px; +} +.form-label-hint { + font-size: 11px; + font-weight: 400; + color: var(--text-quaternary); +} + +.form-input, .form-textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--border-default); + background: var(--bg-primary); + color: var(--text-primary); + border-radius: 8px; + font-size: 14px; + font-family: inherit; + line-height: 1.5; + transition: border-color .15s, box-shadow .15s; +} +.form-input:focus, .form-textarea:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} +.form-textarea { resize: vertical; min-height: 160px; } + +.file-drop { + padding: 24px; + border: 1.5px dashed var(--border-strong); + border-radius: 8px; + background: var(--bg-secondary); + text-align: center; + font-size: 13px; + color: var(--text-tertiary); + transition: border-color .15s, background .15s; + cursor: default; +} +.file-drop.drag-over { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 5%, var(--bg-secondary)); +} +.file-drop.has-file { + border-style: solid; + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 4%, var(--bg-secondary)); + text-align: left; +} +.file-drop-text { color: var(--text-secondary); } +.btn-link { + background: transparent; + border: none; + color: var(--primary); + cursor: pointer; + font-size: inherit; + font-family: inherit; + text-decoration: underline; + padding: 0; +} +.btn-link:hover { color: var(--primary-hover); } +.file-pill { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-primary); +} +.file-pill-clear { + background: transparent; + border: none; + color: var(--text-quaternary); + cursor: pointer; + font-size: 14px; + padding: 2px 6px; + border-radius: 4px; +} +.file-pill-clear:hover { color: #dc2626; background: rgba(239,68,68,0.08); } + +.form-actions { + display: flex; + align-items: center; + gap: 14px; + padding-top: 16px; + border-top: 1px solid var(--border-default); + flex-wrap: wrap; +} +.btn-lg { padding: 12px 26px; font-size: 14px; font-weight: 600; } +.run-hint { font-size: 12.5px; color: var(--text-tertiary); } + +/* Thanks card */ +.thanks-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 56px 32px; + text-align: center; + box-shadow: var(--shadow-card); +} +.thanks-icon { + width: 56px; + height: 56px; + color: #16a34a; + margin: 0 auto 18px; +} +.thanks-title { + font-size: 22px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; +} +.thanks-text { + font-size: 14px; + color: var(--text-secondary); + max-width: 480px; + margin: 0 auto 28px; + line-height: 1.55; +} +.thanks-actions { + display: flex; + justify-content: center; + gap: 12px; + flex-wrap: wrap; +} + +/* Back link */ +.back-link { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 12px 6px 10px; border-radius: 8px; + font-size: 13px; font-weight: 500; color: var(--text-tertiary); + text-decoration: none; border: 0.5px solid var(--border-default); + background: var(--bg-primary); flex-shrink: 0; + transition: color .15s, border-color .15s, background .15s; +} +.back-link:hover { + color: var(--primary); border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); +} +.back-link svg { opacity: 0.8; } +@media (max-width: 640px) { + .back-link span { display: none; } + .back-link { padding: 6px; } +} diff --git a/feature-request/static/index.html b/feature-request/static/index.html new file mode 100644 index 0000000..a922884 --- /dev/null +++ b/feature-request/static/index.html @@ -0,0 +1,151 @@ + + + + + + Návrh nového nástroje | Colsys AI + + + + + + + + + +
+
+ + C + Colsys AI + + Návrh nového nástroje + + + Zpět na portál + +
+
+ +
+ +
+
+

Chybí vám nějaký nástroj?

+

+ Popište, co byste potřebovali. Pokud máte ukázkový soubor + (faktura, smlouva, výkres…), přiložte ho — pomůže nám pochopit + kontext. Návrh se uloží a my se na něj podíváme. +

+
+ +
+
+ + +
+ +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ +
+ + Přetáhněte soubor sem nebo + + + +
+
+ +
+ + Návrhy ukládáme interně — uvidí je jen tým, který portál vyvíjí. +
+
+
+ + + + + +
+ + + + diff --git a/feature-request/static/styles.css b/feature-request/static/styles.css new file mode 100644 index 0000000..9efda74 --- /dev/null +++ b/feature-request/static/styles.css @@ -0,0 +1,461 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f2f4f7; + --text-primary: #101828; + --text-secondary: #354052; + --text-tertiary: #676f83; + --text-quaternary: #98a2b2; + --border-default: rgb(16 24 40 / 0.08); + --border-strong: #d0d5dc; + --border-subtle: rgb(16 24 40 / 0.04); + --card: #ffffff; + --primary: #155aef; + --primary-hover: #004aeb; + --accent-indigo: #444ce7; + --shadow-card: 0 1px 2px rgb(16 24 40 / 0.05); + --radius-md: 8px; + --radius-lg: 12px; +} + +/* Dark theme — applies when (a) user OS prefers dark and no .light override, + or (b) :root has explicit .dark class (set by portal_theme cookie). */ +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; + } +} +:root.dark { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; +} + +body { + font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 100vh; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Header ─────────────────────────────────────── */ +.header { + position: sticky; + top: 0; + z-index: 30; + border-bottom: 0.5px solid var(--border-default); + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + backdrop-filter: blur(8px); +} +.header-inner { + max-width: 1280px; /* match portal max-w-7xl so brand doesn't shift */ + margin: 0 auto; + height: 56px; /* portal uses h-14 */ + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; /* portal px-4 */ + gap: 16px; +} +@media (min-width: 640px) { + .header-inner { padding: 0 32px; } /* portal sm:px-8 */ +} +.brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} +.brand-icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%); + box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 12px; + letter-spacing: -0.04em; + color: white; +} +.brand-name { + font-size: 14px; /* tailwind text-sm */ + font-weight: 600; + letter-spacing: -0.025em; /* tailwind tracking-tight */ + color: var(--text-primary); +} +.brand-ai { color: var(--primary); } +.header-crumb { + font-size: 13px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Main ────────────────────────────────────────── */ +.main { + max-width: 900px; + margin: 0 auto; + padding: 40px 24px 80px; + display: flex; + flex-direction: column; + gap: 32px; +} +@media (max-width: 640px) { + .header-inner { padding: 0 16px; } + .main { padding: 24px 16px 60px; } +} + +/* ── Section intro ───────────────────────────────── */ +.section-intro { margin-bottom: 24px; } +.section-title { + font-size: 22px; + font-weight: 600; + letter-spacing: -0.02em; + margin-bottom: 8px; +} +.section-desc { + color: var(--text-tertiary); + max-width: 580px; + font-size: 14px; + line-height: 1.6; +} + +/* ── Examples panel ──────────────────────────────── */ +.examples-panel { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--card); + box-shadow: var(--shadow-card); + padding: 16px; + margin-bottom: 16px; +} +.examples-header { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 12px; +} +.examples-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} +.examples-subtitle { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; +} +.examples-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 28px; + margin-bottom: 10px; +} +.examples-list:empty::before { + content: "Zatím žádné vzory — přidejte alespoň jeden níže"; + font-size: 12px; + color: var(--text-quaternary); + font-style: italic; + padding: 6px 0; +} +.example-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent); + color: var(--text-primary); + padding: 4px 4px 4px 10px; + border-radius: 999px; + font-size: 12px; + font-family: ui-monospace, monospace; +} +.example-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-tertiary); + padding: 0; + transition: background 0.1s, color 0.1s; +} +.example-chip-remove:hover { + background: color-mix(in srgb, var(--primary) 18%, transparent); + color: var(--text-primary); +} +.example-chip-remove svg { + width: 12px; + height: 12px; +} +.examples-input-row { + display: flex; + gap: 8px; +} +.example-input { + flex: 1; + font-family: ui-monospace, monospace; + font-size: 13px; + padding: 8px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-primary); + min-width: 0; +} +.example-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} +.example-add-btn { + flex-shrink: 0; +} + +/* ── Drop zone ───────────────────────────────────── */ +.drop-zone { + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--card); + padding: 56px 32px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + box-shadow: var(--shadow-card); +} +.drop-zone:hover, .drop-zone.drag-over { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 4%, var(--card)); +} +.drop-icon { + width: 44px; + height: 44px; + color: var(--text-quaternary); + margin: 0 auto 18px; + display: block; + transition: color 0.15s; +} +.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon { + color: var(--primary); +} +.drop-text { + font-size: 15px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} +.drop-or { + font-size: 13px; + color: var(--text-quaternary); + margin-bottom: 14px; +} +.drop-formats { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 14px; +} + +/* ── Buttons ─────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.15s, box-shadow 0.15s; + line-height: 1; +} +.btn-primary { + background: var(--primary); + color: #fff; + box-shadow: 0 1px 2px rgb(21 90 239 / 0.25); +} +.btn-primary:hover { background: var(--primary-hover); } +.btn-primary:disabled { opacity: 0.5; cursor: default; } +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-strong); +} +.btn-secondary:hover { background: var(--border-strong); } +.btn-icon { width: 15px; height: 15px; flex-shrink: 0; } + +/* ── Processing card ─────────────────────────────── */ +.processing-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 56px 32px; + text-align: center; + box-shadow: var(--shadow-card); +} +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border-strong); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin: 0 auto 22px; +} +@keyframes spin { to { transform: rotate(360deg); } } +.processing-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 20px; + color: var(--text-primary); +} +.steps-list { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; + margin: 0 auto; + text-align: left; +} +.step-item { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text-tertiary); + padding: 8px 12px; + border-radius: var(--radius-md); + background: var(--bg-tertiary); +} +.step-item.active { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); +} +.step-item.done { color: #17b26a; } +.step-item.error { color: #d92d20; background: #fef3f2; } +.step-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +/* ── Results ─────────────────────────────────────── */ +.results-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.results-title { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.015em; + margin-bottom: 4px; +} +.results-meta { + font-size: 13px; + color: var(--text-tertiary); +} +.results-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ── Table ───────────────────────────────────────── */ +.table-wrap { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-card); + background: var(--card); + overflow-x: auto; +} +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + min-width: 500px; +} +thead { + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-default); +} +th { + padding: 10px 14px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + white-space: nowrap; +} +td { + padding: 8px 14px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-primary); + vertical-align: middle; +} +tr:last-child td { border-bottom: none; } +tr:nth-child(even) td { background: var(--bg-secondary); } +td[contenteditable]:focus { + outline: none; + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); + box-shadow: inset 0 0 0 1.5px var(--primary); + border-radius: 3px; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 11px; + font-weight: 500; +} +.badge-rule { background: #d1e0ff; color: #004aeb; } +.badge-llm { background: #d1fae5; color: #065f46; } +@media (prefers-color-scheme: dark) { + .badge-rule { background: #1e3a8a; color: #93c5fd; } + .badge-llm { background: #064e3b; color: #6ee7b7; } +} + +.table-hint { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 8px; +} + +.hidden { display: none !important; } diff --git a/ideas/Instrukce_projektu_kontrola_položek_VV.md b/ideas/Instrukce_projektu_kontrola_položek_VV.md new file mode 100644 index 0000000..60e28a5 --- /dev/null +++ b/ideas/Instrukce_projektu_kontrola_položek_VV.md @@ -0,0 +1,89 @@ +Jsi asistent pro kontrolu interní kalkulace cenových nabídek v Excelu. Tvým úkolem je najít všechny listy typu „VV“ a zkontrolovat, jestli se stejné položky (dle názvu) oceňují ve všech těchto listech stejnou jednotkovou cenou. + +ROLE +Jsi technický kontrolor cen v interní kalkulaci pro elektro / MaR projekty. Pracuješ s jedním Excel sešitem, který obsahuje více listů (některé jsou VV, jiné jsou pracovní listy, ceníky apod.). + +ÚKOL +Najdi v sešitu všechny listy, které jsou výkazy výměr (VV). + +Z těchto listů vytáhni položky a porovnej položky se stejným názvem napříč všemi VV. + +Pokud má položka se stejným názvem v různých VV jinou jednotkovou cenu, zapiš tento nesoulad do přehledné reportovací tabulky. + +VSTUPY +Jeden Excel sešit s interní kalkulací nabídky. + +V sešitě je více listů; jen některé jsou VV (výkaz výměr). + +WORKFLOW +Identifikace listů VV + +Projdi všechny listy v sešitu. + +List považuj za VV, pokud to odpovídá názvu nebo struktuře (např. název obsahuje „VV“ nebo list má typickou tabulku VV: číslo položky, název, MJ, množství, jednotková cena apod.). + +Ostatní listy (ceníky, pomocné kalkulace atd.) ignoruj. + +Načtení položek z VV + +Z každého listu VV si načti všechny řádky s položkami (ignoruj součty, mezisoučty, nadpisy sekcí apod.). + +U každé položky ulož minimálně: + +název položky (text), + +jednotkovou cenu, + +měnu (pokud je), + +název/list VV, ze kterého pochází. + +Porovnání položek podle názvu + +Vytvoř seznam všech názvů položek, které se vyskytují alespoň na dvou různých listech VV. + +Pro každý takový název porovnej jednotkové ceny ve všech listech VV, kde se vyskytuje. + +Zohledni, že položky musí být skutečně totožné – porovnávej přesný název (včetně diakritiky a mezer). Pokud je zjevná drobná formální odchylka (např. jiný počet mezer), položku můžeš přesto považovat za stejnou, pokud je význam jasně identický. + +Detekce nesouladů + +Pokud má stejný název položky ve dvou nebo více VV různou jednotkovou cenu, označ to jako nesoulad. + +Nesoulad hlásíš pouze v případě rozdílné jednotkové ceny; rozdílné množství neřeš (to může být různé pro každou zakázku / část). + +Výstupní tabulka (report) +Připrav přehlednou tabulku s nesouladnými položkami ve struktuře (v češtině): + +Název položky + +List VV (název listu) + +Jednotková cena + +Jednotka (pokud je) + +Poznámka (např. „jiná cena než na listu VV_XYZ“) + +Tabulka by měla obsahovat všechny kombinace „název položky + list“, kde se cena liší. Můžeš například uložit každou položku jako samostatný řádek a v poznámce popsat, k čemu je rozdíl. + +Shrnutí + +Na konec přidej krátké slovní shrnutí: kolik různých názvů položek má rozdílné ceny a kolik řádků v reportu vzniklo. + +Pokud žádný nesoulad nenajdeš, napiš jasně, že všechny položky se stejným názvem mají shodné jednotkové ceny ve všech VV. + +PRAVIDLA +Neměň žádná data v originálním souboru, jen je čti a analyzuj. + +Nepracuj s nákladovými interními cenami, ceníky ani jinými pracovními listy, pokud nejsou výslovně označené jako VV. + +Pokud si nejsi jistý, zda je list VV, raději ho popiš v poznámce a požádej o potvrzení. + +Odpovídej česky. + +VÝSTUP +Tabulka s nesouladnými položkami ve formátu vhodném pro vložení do Excelu (markdown tabulka nebo CSV struktura). + +Stručné slovní shrnutí zjištěných rozdílů. + diff --git a/ideas/Instrukce_projektu_změna_VV (1).md b/ideas/Instrukce_projektu_změna_VV (1).md new file mode 100644 index 0000000..1ed774d --- /dev/null +++ b/ideas/Instrukce_projektu_změna_VV (1).md @@ -0,0 +1,83 @@ +# Instrukce projektu „Změna ve VV" + +Zkopírujte text níže (mezi čarami) do nastavení projektu v Cowork (Project instructions). + +--- + +Asistent pro porovnání výkazu výměr (VV) ve formátu MaR (Měření a regulace). + +**Vstupy:** +- Vždy načti **původní** soubor (typicky pojmenovaný „_stávající_…" nebo „…původní…"). +- Vždy načti **nový** soubor (typicky „_nový_…" nebo „…změna…"). +- Pokud uživatel nahraje soubory s jinými názvy, použij je v pořadí: první nahraný = původní, druhý = nový (a tuto domněnku v odpovědi explicitně uveď). +- Soubory mají typicky více listů (např. PS561, PS561 (2), PS561 (3), PS561 (4)) — zpracuj **všechny odpovídající listy** z obou souborů. + +**Struktura zdrojových listů:** +- Řádky 1–5: hlavička s názvem haly/objektu (řádek 4, sloupec C). +- Řádek 6: hlavička tabulky — Poř. | Kód | Popis | MJ | Výměra | Jedn. cena | Cena. +- Od řádku 8: data, kde sekce („001: Rozvaděče", „002: Koncové prvky", …) jsou rozpoznatelné podle prázdné MJ a popisu obsahujícího dvojtečku. + +**Definice „změny":** +- **Změněná položka** = stejný popis a sekce v obou souborech, ale liší se **výměra** nebo **MJ**. Rozdíly v jednotkové ceně se NEPOČÍTAJÍ jako změna (nový VV obvykle ceny neobsahuje). +- **Přidaná položka** = je v novém VV, ale ne v původním (párování podle sekce + popis). +- **Odebraná položka** = byla v původním VV, ale v novém už není. + +**Hlavní úkol — vytvoř Excel soubor `Porovnání_VV_původní_vs_nový.xlsx` se 4 listy:** + +1. **Souhrn** (bez cen) + - Modrý nadpis přes celou šířku, podtitul s názvy souborů (kurzíva, šedá). + - Tabulka: List | Hala / objekt | Počet pol. (původní) | Počet pol. (nový) | Změněné položky | Přidané položky | Odebrané položky. + - Modré záhlaví tabulky (RGB: 1F4E78), bílý tučný text Arial 11. + - Buňky se změněnými/přidanými/odebranými hodnotami zvýraznit barevně podle typu (žlutá / zelená / červená — viz níže), tučně, pokud je hodnota > 0. + - Závěrečný řádek „CELKEM" se sumami (formule SUM), šedé pozadí (RGB: F2F2F2), tučně. + - Pod tabulkou box „Rekapitulace změn (celkem)" se třemi řádky: Změněné / Přidané / Odebrané + počty. + - Vysvětlující poznámka kurzívou: „Za „změnu" je považován pouze rozdíl ve výměře nebo MJ. Cenové rozdíly se ignorují, protože nový VV obvykle ceny neobsahuje." + +2. **Změny** — list s rozdíly ve výměře / MJ + - Sloupce: List | Hala | Sekce | Popis položky | MJ orig. | MJ nová | Výměra orig. | Výměra nová | Rozdíl výměra | Jed. cena orig. | Cena orig. | Cena nová. + - Buňka „Rozdíl výměra" zvýrazněna: zeleně (RGB: E4F0DC) pokud kladný, červeně (RGB: FCE4E4) pokud záporný, tučně. + - Freeze panes na řádek 4, autofilter. + +3. **Přidané položky** + - Sloupce: List | Hala | Sekce | Popis položky | MJ | Výměra | Jed. cena | Cena. + - Celé řádky podbarvené zeleně (RGB: D9EAD3). + +4. **Odebrané položky** + - Stejné sloupce jako Přidané. + - Celé řádky podbarvené červeně (RGB: F4CCCC). + - Na konci součet sloupce Cena (formule SUM), šedé pozadí, tučně. + +**Společné formátování:** +- Font Arial, body 10 (záhlaví 11). +- Tenké šedé ohraničení (RGB: BFBFBF) všech buněk s daty. +- Měna: `#,##0.00 "Kč";[Red]-#,##0.00 "Kč";"-"`. +- Čísla: `#,##0.##;[Red]-#,##0.##;"-"`. +- Modrý nadpis na každém listu: Arial bold 14, RGB: 1F4E78. +- Po vytvoření vždy spustit `recalc.py` a ověřit nulové chyby formulí. + +**Barvy (souhrn):** +- Modré záhlaví: 1F4E78 / bílý text +- Žlutá (změny): FFF2CC +- Zelená (přidáno): D9EAD3 +- Červená (odebráno): F4CCCC +- Tmavě zelený text: 006100 +- Tmavě červený text: C00000 +- Šedý titulek/poznámka: 595959, kurzíva +- Šedý součet: F2F2F2 + +**Výstup:** +- Uložit do složky projektu jako `Porovnání_VV_původní_vs_nový.xlsx`. +- V chatu uvést stručné shrnutí: počty změněných / přidaných / odebraných položek, zmínit nejvýraznější změny (výměry, které se změnily o více než řád), upozornit pokud nový VV nemá doplněné jednotkové ceny. +- Odkaz na vytvořený soubor (computer:// link). + +**Co NEdělat:** +- NEpočítat za změnu rozdíl pouze v jednotkové ceně, pokud se výměra/MJ nezměnila. +- NEcharakterizovat položky s prázdnou cenou v novém VV jako „odebrané" — ty nejsou odebrané. +- NEvytvářet souhrny celkových cen v listu Souhrn — celkové ceny v novém VV obvykle vychází 0 a souhrn by byl matoucí. Cenové údaje patří jen do detailních listů (Změny / Přidané / Odebrané). +- NEpřidávat další listy nad rámec čtyř výše uvedených, pokud o to uživatel výslovně nepožádá. + +--- + +## Pokud se struktura souborů liší od očekávané + +Pokud nahrané soubory mají neočekávanou strukturu (např. jiný list, jiný layout), nejprve se uživatele zeptej (AskUserQuestion), které listy má porovnat — neodhazuj data automaticky. diff --git a/invoice-extractor/.env.example b/invoice-extractor/.env.example new file mode 100644 index 0000000..1bdec6f --- /dev/null +++ b/invoice-extractor/.env.example @@ -0,0 +1,3 @@ +LITELLM_BASE_URL=http://host.docker.internal:4000/v1 +LITELLM_API_KEY=sk-... +LLM_MODEL=anthropic/claude-sonnet-4-20250514 diff --git a/invoice-extractor/Dockerfile b/invoice-extractor/Dockerfile new file mode 100644 index 0000000..252212b --- /dev/null +++ b/invoice-extractor/Dockerfile @@ -0,0 +1,14 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /tmp/invoice-extractor + +EXPOSE 8000 + +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/invoice-extractor/docker-compose.yml b/invoice-extractor/docker-compose.yml new file mode 100644 index 0000000..647bca1 --- /dev/null +++ b/invoice-extractor/docker-compose.yml @@ -0,0 +1,24 @@ +services: + invoice-extractor: + build: . + container_name: invoice-extractor + restart: unless-stopped + ports: + - "127.0.0.1:3029:8000" + environment: + LITELLM_BASE_URL: ${LITELLM_BASE_URL:-http://host.docker.internal:4000/v1} + LITELLM_API_KEY: ${LITELLM_API_KEY:-} + LLM_MODEL: ${LLM_MODEL:-anthropic/claude-sonnet-4-20250514} + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - localai + volumes: + - invoice-extractor-data:/tmp/invoice-extractor + +volumes: + invoice-extractor-data: + +networks: + localai: + external: true diff --git a/invoice-extractor/excel_export.py b/invoice-extractor/excel_export.py new file mode 100644 index 0000000..07eb064 --- /dev/null +++ b/invoice-extractor/excel_export.py @@ -0,0 +1,154 @@ +"""Render extracted invoice data into a tidy XLSX file.""" +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter + + +HEADER_FILL = PatternFill("solid", fgColor="2563EB") +HEADER_FONT = Font(bold=True, color="FFFFFF", size=11) +LABEL_FONT = Font(bold=True, size=10) +THIN = Side(style="thin", color="D0D5DC") +BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) + + +def write_invoice_xlsx(data: dict, out_path: str) -> None: + wb = Workbook() + ws = wb.active + ws.title = "Faktura" + + # ── Header section ── + ws.cell(row=1, column=1, value="FAKTURA — extrahovaná data").font = Font(bold=True, size=14) + ws.merge_cells("A1:D1") + + row = 3 + row = _write_kv_block(ws, row, "Identifikace faktury", [ + ("Číslo faktury", data.get("invoice_number")), + ("Variabilní symbol", data.get("variable_symbol")), + ("Konstantní symbol", data.get("constant_symbol")), + ("Specifický symbol", data.get("specific_symbol")), + ("Datum vystavení", data.get("issue_date")), + ("Datum splatnosti", data.get("due_date")), + ("DUZP", data.get("taxable_date")), + ("Měna", data.get("currency") or "CZK"), + ("Způsob platby", data.get("payment_method")), + ("Číslo účtu", data.get("bank_account")), + ("IBAN", data.get("iban")), + ]) + row += 1 + + sup = data.get("supplier") or {} + row = _write_kv_block(ws, row, "Dodavatel", [ + ("Název", sup.get("name")), + ("IČO", sup.get("ico")), + ("DIČ", sup.get("dic")), + ("Adresa", sup.get("address")), + ]) + row += 1 + + cust = data.get("customer") or {} + row = _write_kv_block(ws, row, "Odběratel", [ + ("Název", cust.get("name")), + ("IČO", cust.get("ico")), + ("DIČ", cust.get("dic")), + ("Adresa", cust.get("address")), + ]) + row += 1 + + # ── Line items table ── + items = data.get("line_items") or [] + if items: + row = _write_section_header(ws, row, "Položky faktury") + headers = ["Popis", "Množství", "Jednotka", "Cena/jed. bez DPH", + "Sazba DPH (%)", "Bez DPH", "S DPH"] + for c, h in enumerate(headers, 1): + cell = ws.cell(row=row, column=c, value=h) + cell.font = HEADER_FONT + cell.fill = HEADER_FILL + cell.alignment = Alignment(horizontal="center") + cell.border = BORDER + row += 1 + for item in items: + values = [ + item.get("description") or "", + item.get("quantity"), + item.get("unit") or "", + item.get("unit_price_excluding_vat"), + item.get("vat_rate"), + item.get("total_excluding_vat"), + item.get("total_including_vat"), + ] + for c, v in enumerate(values, 1): + cell = ws.cell(row=row, column=c, value=v) + cell.border = BORDER + if c >= 4 and isinstance(v, (int, float)): + cell.number_format = "#,##0.00" + row += 1 + row += 1 + + # ── VAT breakdown ── + vat = data.get("vat_breakdown") or [] + if vat: + row = _write_section_header(ws, row, "Rekapitulace DPH") + headers = ["Sazba (%)", "Základ", "DPH", "Celkem"] + for c, h in enumerate(headers, 1): + cell = ws.cell(row=row, column=c, value=h) + cell.font = HEADER_FONT + cell.fill = HEADER_FILL + cell.alignment = Alignment(horizontal="center") + cell.border = BORDER + row += 1 + for br in vat: + for c, v in enumerate([br.get("rate"), br.get("base"), + br.get("vat"), br.get("total")], 1): + cell = ws.cell(row=row, column=c, value=v) + cell.border = BORDER + if c >= 2 and isinstance(v, (int, float)): + cell.number_format = "#,##0.00" + row += 1 + row += 1 + + # ── Totals ── + row = _write_section_header(ws, row, "Celkem") + totals = [ + ("Celkem bez DPH", data.get("total_excluding_vat")), + ("Celkem DPH", data.get("total_vat")), + ("CELKEM K ÚHRADĚ", data.get("total_including_vat")), + ] + for label, val in totals: + ws.cell(row=row, column=1, value=label).font = LABEL_FONT + cell = ws.cell(row=row, column=2, value=val) + if isinstance(val, (int, float)): + cell.number_format = "#,##0.00" + if label.startswith("CELKEM K"): + ws.cell(row=row, column=1).font = Font(bold=True, size=12) + cell.font = Font(bold=True, size=12) + row += 1 + + if data.get("notes"): + row += 1 + ws.cell(row=row, column=1, value="Poznámka:").font = LABEL_FONT + ws.cell(row=row, column=2, value=data["notes"]) + + # ── Auto-widths ── + widths = {1: 32, 2: 14, 3: 10, 4: 16, 5: 14, 6: 14, 7: 14} + for c, w in widths.items(): + ws.column_dimensions[get_column_letter(c)].width = w + + wb.save(out_path) + + +def _write_section_header(ws, row: int, text: str) -> int: + cell = ws.cell(row=row, column=1, value=text) + cell.font = Font(bold=True, size=12, color="0F1729") + cell.fill = PatternFill("solid", fgColor="EFF4FF") + ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=7) + return row + 1 + + +def _write_kv_block(ws, row: int, header: str, pairs: list[tuple]) -> int: + row = _write_section_header(ws, row, header) + for label, val in pairs: + ws.cell(row=row, column=1, value=label).font = LABEL_FONT + ws.cell(row=row, column=2, value=val if val is not None else "") + row += 1 + return row diff --git a/invoice-extractor/extractor.py b/invoice-extractor/extractor.py new file mode 100644 index 0000000..c67b7bf --- /dev/null +++ b/invoice-extractor/extractor.py @@ -0,0 +1,190 @@ +"""Invoice data extraction via Claude vision.""" +import base64 +import io +import json +import logging +import os +from pathlib import Path + +import fitz # PyMuPDF +from PIL import Image +from openai import AsyncOpenAI + +logger = logging.getLogger(__name__) + +_client: AsyncOpenAI | None = None +MODEL = os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-20250514") +MAX_PAGES = 6 # cap to keep token cost predictable +MAX_IMG_LONG_EDGE = 2200 # px — enough resolution for invoice details + + +def _get_client() -> AsyncOpenAI: + global _client + if _client is None: + _client = AsyncOpenAI( + base_url=os.getenv("LITELLM_BASE_URL", "http://host.docker.internal:4000/v1"), + api_key=os.getenv("LITELLM_API_KEY", "sk-dummy"), + ) + return _client + + +SYSTEM_PROMPT = """Jste přesný extraktor dat z českých faktur. Z obrázku/obrázků faktury vyextrahujete strukturovaná data pro účetní systém. + +Vraťte POUZE platný JSON v tomto tvaru, žádné markdown obaly, žádný komentář mimo JSON: + +{ + "invoice_number": "string|null", + "issue_date": "YYYY-MM-DD|null", + "due_date": "YYYY-MM-DD|null", + "taxable_date": "YYYY-MM-DD|null", + "variable_symbol": "string|null", + "constant_symbol": "string|null", + "specific_symbol": "string|null", + "currency": "CZK", + "supplier": { + "name": "string|null", + "ico": "string|null", + "dic": "string|null", + "address": "string|null" + }, + "customer": { + "name": "string|null", + "ico": "string|null", + "dic": "string|null", + "address": "string|null" + }, + "bank_account": "string|null", + "iban": "string|null", + "payment_method": "převod|hotově|karta|dobírka|jiné|null", + "line_items": [ + { + "description": "string", + "quantity": "number|null", + "unit": "string|null", + "unit_price_excluding_vat": "number|null", + "vat_rate": "number|null", + "total_excluding_vat": "number|null", + "total_including_vat": "number|null" + } + ], + "vat_breakdown": [ + {"rate": 21, "base": 0.0, "vat": 0.0, "total": 0.0} + ], + "total_excluding_vat": "number|null", + "total_vat": "number|null", + "total_including_vat": "number|null", + "notes": "string|null" +} + +Pravidla: +- Datumy zapisujte ve formátu YYYY-MM-DD (ISO 8601). +- Čísla zapisujte jako desetinná čísla s tečkou jako oddělovačem (např. 1234.56). NIKDY nepoužívejte čárku ani mezery v číslech. +- Pokud údaj na faktuře není, použijte null. NEVYMÝŠLEJTE si. +- IČO je 8místné číslo (může mít vedoucí nuly). DIČ obvykle začíná „CZ". +- variable_symbol je obvykle stejný jako číslo faktury, ale ne vždy — zapisujte přesně co je na faktuře. +- U položek (line_items) zachovejte přesné pořadí jak jsou na faktuře. +- Pokud je faktura v jiné měně než CZK, zapište správný kód měny (EUR, USD, atd.). +- Adresy zapisujte jako jeden řetězec (ulice + číslo, PSČ město).""" + + +async def extract_invoice(pdf_or_image_path: Path) -> dict: + """Extract structured invoice data using Claude vision.""" + images = _to_images(pdf_or_image_path) + if not images: + raise RuntimeError("Soubor neobsahuje žádné zobrazitelné stránky") + + content = [ + {"type": "text", + "text": "Z následujících obrázků faktury vyextrahujte strukturovaná data podle definovaného JSON tvaru."}, + ] + for img_b64 in images: + content.append({ + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{img_b64}"}, + }) + + try: + resp = await _get_client().chat.completions.create( + model=MODEL, + messages=[ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": content}, + ], + temperature=0.0, + max_tokens=4000, + ) + except Exception as exc: + logger.exception("LLM call failed") + raise RuntimeError(f"AI extrakce selhala: {exc}") + + raw = (resp.choices[0].message.content or "").strip() + raw = raw.removeprefix("```json").removeprefix("```").removesuffix("```").strip() + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + logger.error("JSON parse failed: %s\n%s", exc, raw[:500]) + raise RuntimeError(f"Nepodařilo se zpracovat odpověď AI: {exc}") + + return _normalize(data) + + +def _to_images(path: Path) -> list[str]: + """Return list of base64-encoded PNG strings, one per page.""" + suffix = path.suffix.lower() + if suffix == ".pdf": + doc = fitz.open(str(path)) + out = [] + for i, page in enumerate(doc): + if i >= MAX_PAGES: + break + pix = page.get_pixmap(dpi=200, alpha=False) + img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples) + out.append(_compress(img)) + doc.close() + return out + elif suffix in (".jpg", ".jpeg", ".png", ".webp"): + img = Image.open(path).convert("RGB") + return [_compress(img)] + raise RuntimeError(f"Nepodporovaný formát: {suffix}") + + +def _compress(img: Image.Image) -> str: + if max(img.size) > MAX_IMG_LONG_EDGE: + ratio = MAX_IMG_LONG_EDGE / max(img.size) + img = img.resize( + (int(img.size[0] * ratio), int(img.size[1] * ratio)), + Image.LANCZOS, + ) + buf = io.BytesIO() + img.save(buf, format="PNG", optimize=True) + return base64.b64encode(buf.getvalue()).decode("ascii") + + +def _normalize(data: dict) -> dict: + """Coerce known numeric fields to float and clean nulls.""" + def _num(v): + if v is None or v == "": + return None + if isinstance(v, (int, float)): + return float(v) + try: + return float(str(v).replace(",", ".").replace(" ", "")) + except (ValueError, TypeError): + return None + + for k in ("total_excluding_vat", "total_vat", "total_including_vat"): + if k in data: + data[k] = _num(data[k]) + + for item in data.get("line_items") or []: + for k in ("quantity", "unit_price_excluding_vat", "vat_rate", + "total_excluding_vat", "total_including_vat"): + if k in item: + item[k] = _num(item[k]) + + for br in data.get("vat_breakdown") or []: + for k in ("rate", "base", "vat", "total"): + if k in br: + br[k] = _num(br[k]) + + return data diff --git a/invoice-extractor/main.py b/invoice-extractor/main.py new file mode 100644 index 0000000..7370a86 --- /dev/null +++ b/invoice-extractor/main.py @@ -0,0 +1,98 @@ +"""FastAPI: invoice PDF/image → structured data → editable form → XLSX export.""" +import asyncio +import logging +import os +import uuid +from pathlib import Path + +from fastapi import FastAPI, File, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles +from pydantic import BaseModel + +from excel_export import write_invoice_xlsx +from extractor import extract_invoice + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="Invoice Extractor") +app.add_middleware(CORSMiddleware, allow_origins=["*"], + allow_methods=["*"], allow_headers=["*"]) + +WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/invoice-extractor")) +WORK_DIR.mkdir(parents=True, exist_ok=True) + +jobs: dict[str, dict] = {} + +ALLOWED_EXT = {".pdf", ".jpg", ".jpeg", ".png", ".webp"} + + +@app.get("/") +async def root(): + return FileResponse("static/index.html") + + +@app.post("/api/upload") +async def upload(file: UploadFile = File(...)): + suffix = Path(file.filename or "").suffix.lower() + if suffix not in ALLOWED_EXT: + raise HTTPException(400, f"Podporované formáty: {', '.join(sorted(ALLOWED_EXT))}") + + job_id = str(uuid.uuid4()) + job_dir = WORK_DIR / job_id + job_dir.mkdir() + input_path = job_dir / f"input{suffix}" + input_path.write_bytes(await file.read()) + logger.info("Job %s: %s (%d bytes)", job_id, file.filename, input_path.stat().st_size) + + try: + data = await extract_invoice(input_path) + except Exception as exc: + logger.exception("Extraction failed") + raise HTTPException(500, str(exc)) + + jobs[job_id] = { + "filename": file.filename, + "job_dir": str(job_dir), + "data": data, + } + return {"job_id": job_id, "data": data} + + +class SaveRequest(BaseModel): + data: dict + + +@app.post("/api/save/{job_id}") +async def save_data(job_id: str, req: SaveRequest): + if job_id not in jobs: + raise HTTPException(404, "Nenalezeno") + jobs[job_id]["data"] = req.data + return {"ok": True} + + +@app.get("/api/export/{job_id}") +async def export(job_id: str): + if job_id not in jobs: + raise HTTPException(404, "Nenalezeno") + job = jobs[job_id] + out_path = Path(job["job_dir"]) / "invoice.xlsx" + write_invoice_xlsx(job["data"], str(out_path)) + inv_no = (job["data"].get("invoice_number") or + Path(job["filename"]).stem if job.get("filename") else "faktura") + safe = "".join(c if c.isalnum() or c in "-_." else "_" for c in str(inv_no)) + return FileResponse( + str(out_path), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + filename=f"faktura_{safe}.xlsx", + ) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/invoice-extractor/requirements.txt b/invoice-extractor/requirements.txt new file mode 100644 index 0000000..b53a627 --- /dev/null +++ b/invoice-extractor/requirements.txt @@ -0,0 +1,8 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +PyMuPDF>=1.24 +Pillow>=10.0 +openpyxl>=3.1 +python-multipart>=0.0.9 +openai>=1.50 +python-dotenv>=1.0 diff --git a/invoice-extractor/static/app.js b/invoice-extractor/static/app.js new file mode 100644 index 0000000..9bd9d53 --- /dev/null +++ b/invoice-extractor/static/app.js @@ -0,0 +1,352 @@ +// Invoice extractor frontend +(() => { + const $ = (id) => document.getElementById(id); + const sections = { + upload: $("s-upload"), + processing: $("s-processing"), + result: $("s-result"), + }; + const show = (name) => { + for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name); + }; + + let state = { jobId: null, data: null }; + + // ── Upload ── + const fileInput = $("file-input"); + const dropZone = $("drop-zone"); + $("browse-btn").addEventListener("click", () => fileInput.click()); + fileInput.addEventListener("change", (e) => e.target.files[0] && upload(e.target.files[0])); + ["dragenter", "dragover"].forEach((ev) => + dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); })); + ["dragleave", "drop"].forEach((ev) => + dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); })); + dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && upload(e.dataTransfer.files[0])); + + async function upload(file) { + show("processing"); + try { + const fd = new FormData(); + fd.append("file", file); + const r = await fetch("/api/upload", { method: "POST", body: fd }); + if (!r.ok) throw new Error((await r.json()).detail || r.statusText); + const json = await r.json(); + state.jobId = json.job_id; + state.data = json.data || {}; + renderForm(); + show("result"); + } catch (err) { + alert("Chyba: " + err.message); + show("upload"); + } + } + + // ── Render editable form ── + function field(key, label, value, opts = {}) { + const isEmpty = value === null || value === undefined || value === ""; + const display = isEmpty ? "" : value; + const cls = "inv-input" + (isEmpty ? " is-empty" : ""); + return ` +
+ + +
`; + } + + function nestedField(parent, key, label, value, opts = {}) { + const isEmpty = value === null || value === undefined || value === ""; + const display = isEmpty ? "" : value; + const cls = "inv-input" + (isEmpty ? " is-empty" : ""); + return ` +
+ + +
`; + } + + function renderForm() { + const d = state.data; + const sup = d.supplier || {}; + const cust = d.customer || {}; + const items = d.line_items || []; + const vat = d.vat_breakdown || []; + + const html = ` + +
+
Identifikace faktury
+
+ ${field("invoice_number", "Číslo faktury", d.invoice_number)} + ${field("variable_symbol", "Variabilní symbol", d.variable_symbol)} + ${field("currency", "Měna", d.currency || "CZK")} + ${field("issue_date", "Datum vystavení", d.issue_date, {type: "date"})} + ${field("due_date", "Datum splatnosti", d.due_date, {type: "date"})} + ${field("taxable_date", "DUZP", d.taxable_date, {type: "date"})} + ${field("constant_symbol", "Konstantní symbol", d.constant_symbol)} + ${field("specific_symbol", "Specifický symbol", d.specific_symbol)} + ${field("payment_method", "Způsob platby", d.payment_method)} +
+
+ + +
+
Dodavatel
+
+ ${nestedField("supplier", "name", "Název", sup.name, {wide: true})} + ${nestedField("supplier", "ico", "IČO", sup.ico)} + ${nestedField("supplier", "dic", "DIČ", sup.dic)} + ${nestedField("supplier", "address", "Adresa", sup.address, {wide: true})} +
+
+ + +
+
Odběratel
+
+ ${nestedField("customer", "name", "Název", cust.name, {wide: true})} + ${nestedField("customer", "ico", "IČO", cust.ico)} + ${nestedField("customer", "dic", "DIČ", cust.dic)} + ${nestedField("customer", "address", "Adresa", cust.address, {wide: true})} +
+
+ + +
+
Platební údaje
+
+ ${field("bank_account", "Číslo účtu", d.bank_account)} + ${field("iban", "IBAN", d.iban)} +
+
+ + +
+
Položky faktury
+
+ + + + + + + + + + + + + + + ${items.map((it, i) => renderItemRow(it, i)).join("")} + +
PopisMnožstvíJednotkaCena/jed. bez DPHDPH %Bez DPHS DPH
+
+
+ +
+
+ + ${vat.length ? ` + +
+
Rekapitulace DPH
+
+ + + + + + + + + + + + ${vat.map((br, i) => renderVatRow(br, i)).join("")} + +
Sazba (%)ZákladDPHCelkem
+
+
` : ""} + + +
+
Celkem
+
+
+ Bez DPH + +
+
+ DPH + +
+
+ K úhradě + +
+
+
+ + ${d.notes ? ` +
+
Poznámka
+ +
` : ""} + `; + $("invoice-form").innerHTML = html; + bindFieldHandlers(); + } + + function renderItemRow(item, i) { + return ` + + + + + + + + + + `; + } + + function renderVatRow(br, i) { + return ` + + + + + + + `; + } + + function bindFieldHandlers() { + // Top-level + nested fields + $("invoice-form").querySelectorAll("input[data-key]").forEach((el) => { + el.addEventListener("input", () => { + const key = el.dataset.key; + const parent = el.dataset.parent; + const val = parseFieldValue(key, el.value); + if (parent) { + if (!state.data[parent]) state.data[parent] = {}; + state.data[parent][key] = val; + } else { + state.data[key] = val; + } + el.classList.toggle("is-empty", el.value === ""); + }); + }); + + // Item rows + const itemsBody = $("items-body"); + if (itemsBody) { + itemsBody.addEventListener("input", (e) => { + const inp = e.target.closest("input"); + if (!inp) return; + const tr = inp.closest("tr"); + const i = +tr.dataset.row; + const field = inp.dataset.field; + if (!state.data.line_items) state.data.line_items = []; + if (!state.data.line_items[i]) state.data.line_items[i] = {}; + const numFields = ["quantity", "unit_price_excluding_vat", "vat_rate", + "total_excluding_vat", "total_including_vat"]; + state.data.line_items[i][field] = + numFields.includes(field) ? parseNum(inp.value) : inp.value; + }); + itemsBody.addEventListener("click", (e) => { + const btn = e.target.closest('[data-action="delete-item"]'); + if (!btn) return; + const tr = btn.closest("tr"); + const i = +tr.dataset.row; + state.data.line_items.splice(i, 1); + renderForm(); + }); + } + const addItem = $("add-item-btn"); + if (addItem) { + addItem.addEventListener("click", () => { + if (!state.data.line_items) state.data.line_items = []; + state.data.line_items.push({description: "", quantity: null, unit: "", + unit_price_excluding_vat: null, vat_rate: null, + total_excluding_vat: null, total_including_vat: null}); + renderForm(); + }); + } + + // VAT rows + const vatBody = $("vat-body"); + if (vatBody) { + vatBody.addEventListener("input", (e) => { + const inp = e.target.closest("input"); + if (!inp) return; + const tr = inp.closest("tr"); + const i = +tr.dataset.row; + const field = inp.dataset.field; + if (!state.data.vat_breakdown) state.data.vat_breakdown = []; + if (!state.data.vat_breakdown[i]) state.data.vat_breakdown[i] = {}; + state.data.vat_breakdown[i][field] = parseNum(inp.value); + }); + vatBody.addEventListener("click", (e) => { + const btn = e.target.closest('[data-action="delete-vat"]'); + if (!btn) return; + const tr = btn.closest("tr"); + const i = +tr.dataset.row; + state.data.vat_breakdown.splice(i, 1); + renderForm(); + }); + } + } + + function parseFieldValue(key, str) { + // Numeric top-level fields + const num = ["total_excluding_vat", "total_vat", "total_including_vat"]; + if (num.includes(key)) return parseNum(str); + return str; + } + function parseNum(s) { + if (s === "" || s == null) return null; + const n = parseFloat(String(s).replace(",", ".").replace(/\s/g, "")); + return Number.isFinite(n) ? n : null; + } + function fmtNum(v) { + if (v === null || v === undefined || v === "") return ""; + if (typeof v === "number") return String(v); + return String(v); + } + + // ── Export ── + $("export-btn").addEventListener("click", async () => { + if (!state.jobId) return; + // Persist edits server-side before downloading + await fetch(`/api/save/${state.jobId}`, { + method: "POST", + headers: {"Content-Type": "application/json"}, + body: JSON.stringify({data: state.data}), + }).catch(() => {}); + window.location.href = `/api/export/${state.jobId}`; + }); + + $("restart-btn").addEventListener("click", () => { + state = { jobId: null, data: null }; + fileInput.value = ""; + $("invoice-form").innerHTML = ""; + show("upload"); + }); + + function escapeHtml(s) { + return String(s ?? "").replace(/[&<>"']/g, (c) => + ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); + } + function escapeHtmlAttr(s) { return escapeHtml(s); } +})(); diff --git a/invoice-extractor/static/extra.css b/invoice-extractor/static/extra.css new file mode 100644 index 0000000..c3d847b --- /dev/null +++ b/invoice-extractor/static/extra.css @@ -0,0 +1,186 @@ +/* Invoice-extractor specific styles */ + +.processing-sub { + font-size: 13px; + color: var(--text-tertiary); + margin: 8px auto 0; + max-width: 400px; +} + +.invoice-form { + display: flex; + flex-direction: column; + gap: 18px; +} + +.inv-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 18px 20px; + box-shadow: var(--shadow-card); +} +.inv-card-title { + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--primary); + margin-bottom: 14px; + padding-bottom: 10px; + border-bottom: 1px solid var(--border-default); +} +.inv-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 16px; +} +.inv-grid-3 { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 12px 16px; +} +@media (max-width: 720px) { + .inv-grid, .inv-grid-3 { grid-template-columns: 1fr; } +} +.inv-field { display: flex; flex-direction: column; gap: 4px; } +.inv-field-wide { grid-column: 1 / -1; } + +.inv-label { + font-size: 11px; + font-weight: 500; + color: var(--text-tertiary); + letter-spacing: 0.02em; +} +.inv-input { + width: 100%; + padding: 8px 10px; + background: var(--bg-secondary); + border: 1px solid transparent; + border-radius: 6px; + color: var(--text-primary); + font-size: 14px; + font-family: inherit; + transition: border-color .15s, background .15s; +} +.inv-input:focus { + outline: none; + border-color: var(--primary); + background: var(--bg-primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} +.inv-input.is-empty { color: var(--text-quaternary); font-style: italic; } +.inv-input.is-empty::placeholder { color: var(--text-quaternary); } + +/* Tables (line items & vat) */ +.inv-table-wrap { overflow-x: auto; } +.inv-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} +.inv-table th, .inv-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--border-default); + text-align: left; +} +.inv-table th { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + background: var(--bg-secondary); +} +.inv-table td input { + width: 100%; + background: transparent; + border: 1px solid transparent; + padding: 4px 6px; + border-radius: 4px; + font-size: 13px; + font-family: inherit; + color: var(--text-primary); +} +.inv-table td input:focus { + outline: none; + background: var(--bg-primary); + border-color: var(--primary); +} +.inv-table td.num input { text-align: right; } +.inv-row-actions { width: 36px; text-align: center; } +.inv-row-delete { + background: transparent; + border: none; + color: var(--text-quaternary); + cursor: pointer; + font-size: 16px; + padding: 2px 6px; + border-radius: 4px; +} +.inv-row-delete:hover { color: #dc2626; background: rgba(239,68,68,0.08); } + +.inv-table-foot { + display: flex; + justify-content: flex-start; + margin-top: 10px; +} +.btn-add-row { + background: transparent; + border: 1px dashed var(--border-strong); + color: var(--text-tertiary); + font-size: 12px; + padding: 6px 12px; + border-radius: 6px; + cursor: pointer; + font-family: inherit; +} +.btn-add-row:hover { color: var(--primary); border-color: var(--primary); } + +.totals-row { + display: flex; + justify-content: flex-end; + gap: 32px; + padding-top: 14px; + border-top: 2px solid var(--border-default); +} +.totals-row .total-cell { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; +} +.totals-row .total-cell-label { + font-size: 11px; + color: var(--text-tertiary); + text-transform: uppercase; + letter-spacing: 0.06em; +} +.totals-row .total-cell-input { + width: 140px; + text-align: right; + font-weight: 600; +} +.totals-row .total-cell.primary .total-cell-input { + font-size: 18px; + color: var(--primary); +} + +/* Back link */ +.back-link { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 12px 6px 10px; border-radius: 8px; + font-size: 13px; font-weight: 500; color: var(--text-tertiary); + text-decoration: none; border: 0.5px solid var(--border-default); + background: var(--bg-primary); flex-shrink: 0; + transition: color .15s, border-color .15s, background .15s; +} +.back-link:hover { + color: var(--primary); border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); +} +.back-link svg { opacity: 0.8; } +@media (max-width: 640px) { + .back-link span { display: none; } + .back-link { padding: 6px; } +} diff --git a/invoice-extractor/static/index.html b/invoice-extractor/static/index.html new file mode 100644 index 0000000..2fb2e8d --- /dev/null +++ b/invoice-extractor/static/index.html @@ -0,0 +1,106 @@ + + + + + + Extrakce faktur | Colsys AI + + + + + + + + + +
+ +
+ +
+ +
+
+

Extrakce dat z faktury

+

+ Nahrajte fakturu (PDF nebo scan) a získáte strukturovaná data + připravená pro účetní systém. Po extrakci můžete data ručně upravit + a stáhnout jako Excel. +

+
+ +
+ + + +

Přetáhněte fakturu sem

+

nebo

+ +

PDF · JPG · PNG · WEBP

+ +
+
+ + + + + +
+ + + + diff --git a/invoice-extractor/static/styles.css b/invoice-extractor/static/styles.css new file mode 100644 index 0000000..9efda74 --- /dev/null +++ b/invoice-extractor/static/styles.css @@ -0,0 +1,461 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f2f4f7; + --text-primary: #101828; + --text-secondary: #354052; + --text-tertiary: #676f83; + --text-quaternary: #98a2b2; + --border-default: rgb(16 24 40 / 0.08); + --border-strong: #d0d5dc; + --border-subtle: rgb(16 24 40 / 0.04); + --card: #ffffff; + --primary: #155aef; + --primary-hover: #004aeb; + --accent-indigo: #444ce7; + --shadow-card: 0 1px 2px rgb(16 24 40 / 0.05); + --radius-md: 8px; + --radius-lg: 12px; +} + +/* Dark theme — applies when (a) user OS prefers dark and no .light override, + or (b) :root has explicit .dark class (set by portal_theme cookie). */ +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; + } +} +:root.dark { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; +} + +body { + font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 100vh; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Header ─────────────────────────────────────── */ +.header { + position: sticky; + top: 0; + z-index: 30; + border-bottom: 0.5px solid var(--border-default); + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + backdrop-filter: blur(8px); +} +.header-inner { + max-width: 1280px; /* match portal max-w-7xl so brand doesn't shift */ + margin: 0 auto; + height: 56px; /* portal uses h-14 */ + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; /* portal px-4 */ + gap: 16px; +} +@media (min-width: 640px) { + .header-inner { padding: 0 32px; } /* portal sm:px-8 */ +} +.brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} +.brand-icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%); + box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 12px; + letter-spacing: -0.04em; + color: white; +} +.brand-name { + font-size: 14px; /* tailwind text-sm */ + font-weight: 600; + letter-spacing: -0.025em; /* tailwind tracking-tight */ + color: var(--text-primary); +} +.brand-ai { color: var(--primary); } +.header-crumb { + font-size: 13px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Main ────────────────────────────────────────── */ +.main { + max-width: 900px; + margin: 0 auto; + padding: 40px 24px 80px; + display: flex; + flex-direction: column; + gap: 32px; +} +@media (max-width: 640px) { + .header-inner { padding: 0 16px; } + .main { padding: 24px 16px 60px; } +} + +/* ── Section intro ───────────────────────────────── */ +.section-intro { margin-bottom: 24px; } +.section-title { + font-size: 22px; + font-weight: 600; + letter-spacing: -0.02em; + margin-bottom: 8px; +} +.section-desc { + color: var(--text-tertiary); + max-width: 580px; + font-size: 14px; + line-height: 1.6; +} + +/* ── Examples panel ──────────────────────────────── */ +.examples-panel { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--card); + box-shadow: var(--shadow-card); + padding: 16px; + margin-bottom: 16px; +} +.examples-header { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 12px; +} +.examples-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} +.examples-subtitle { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; +} +.examples-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 28px; + margin-bottom: 10px; +} +.examples-list:empty::before { + content: "Zatím žádné vzory — přidejte alespoň jeden níže"; + font-size: 12px; + color: var(--text-quaternary); + font-style: italic; + padding: 6px 0; +} +.example-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent); + color: var(--text-primary); + padding: 4px 4px 4px 10px; + border-radius: 999px; + font-size: 12px; + font-family: ui-monospace, monospace; +} +.example-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-tertiary); + padding: 0; + transition: background 0.1s, color 0.1s; +} +.example-chip-remove:hover { + background: color-mix(in srgb, var(--primary) 18%, transparent); + color: var(--text-primary); +} +.example-chip-remove svg { + width: 12px; + height: 12px; +} +.examples-input-row { + display: flex; + gap: 8px; +} +.example-input { + flex: 1; + font-family: ui-monospace, monospace; + font-size: 13px; + padding: 8px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-primary); + min-width: 0; +} +.example-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} +.example-add-btn { + flex-shrink: 0; +} + +/* ── Drop zone ───────────────────────────────────── */ +.drop-zone { + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--card); + padding: 56px 32px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + box-shadow: var(--shadow-card); +} +.drop-zone:hover, .drop-zone.drag-over { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 4%, var(--card)); +} +.drop-icon { + width: 44px; + height: 44px; + color: var(--text-quaternary); + margin: 0 auto 18px; + display: block; + transition: color 0.15s; +} +.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon { + color: var(--primary); +} +.drop-text { + font-size: 15px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} +.drop-or { + font-size: 13px; + color: var(--text-quaternary); + margin-bottom: 14px; +} +.drop-formats { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 14px; +} + +/* ── Buttons ─────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.15s, box-shadow 0.15s; + line-height: 1; +} +.btn-primary { + background: var(--primary); + color: #fff; + box-shadow: 0 1px 2px rgb(21 90 239 / 0.25); +} +.btn-primary:hover { background: var(--primary-hover); } +.btn-primary:disabled { opacity: 0.5; cursor: default; } +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-strong); +} +.btn-secondary:hover { background: var(--border-strong); } +.btn-icon { width: 15px; height: 15px; flex-shrink: 0; } + +/* ── Processing card ─────────────────────────────── */ +.processing-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 56px 32px; + text-align: center; + box-shadow: var(--shadow-card); +} +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border-strong); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin: 0 auto 22px; +} +@keyframes spin { to { transform: rotate(360deg); } } +.processing-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 20px; + color: var(--text-primary); +} +.steps-list { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; + margin: 0 auto; + text-align: left; +} +.step-item { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text-tertiary); + padding: 8px 12px; + border-radius: var(--radius-md); + background: var(--bg-tertiary); +} +.step-item.active { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); +} +.step-item.done { color: #17b26a; } +.step-item.error { color: #d92d20; background: #fef3f2; } +.step-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +/* ── Results ─────────────────────────────────────── */ +.results-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.results-title { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.015em; + margin-bottom: 4px; +} +.results-meta { + font-size: 13px; + color: var(--text-tertiary); +} +.results-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ── Table ───────────────────────────────────────── */ +.table-wrap { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-card); + background: var(--card); + overflow-x: auto; +} +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + min-width: 500px; +} +thead { + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-default); +} +th { + padding: 10px 14px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + white-space: nowrap; +} +td { + padding: 8px 14px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-primary); + vertical-align: middle; +} +tr:last-child td { border-bottom: none; } +tr:nth-child(even) td { background: var(--bg-secondary); } +td[contenteditable]:focus { + outline: none; + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); + box-shadow: inset 0 0 0 1.5px var(--primary); + border-radius: 3px; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 11px; + font-weight: 500; +} +.badge-rule { background: #d1e0ff; color: #004aeb; } +.badge-llm { background: #d1fae5; color: #065f46; } +@media (prefers-color-scheme: dark) { + .badge-rule { background: #1e3a8a; color: #93c5fd; } + .badge-llm { background: #064e3b; color: #6ee7b7; } +} + +.table-hint { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 8px; +} + +.hidden { display: none !important; } diff --git a/landing/.dockerignore b/landing/.dockerignore new file mode 100644 index 0000000..03a54c4 --- /dev/null +++ b/landing/.dockerignore @@ -0,0 +1,8 @@ +node_modules +.next +.git +.env +.env.local +Dockerfile +.dockerignore +README.md diff --git a/landing/.gitignore b/landing/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/landing/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/landing/AGENTS.md b/landing/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/landing/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/landing/CLAUDE.md b/landing/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/landing/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/landing/Dockerfile b/landing/Dockerfile new file mode 100644 index 0000000..42ce297 --- /dev/null +++ b/landing/Dockerfile @@ -0,0 +1,32 @@ +FROM node:23-alpine AS base +RUN corepack enable + +FROM base AS deps +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile + +FROM base AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +ENV NEXT_TELEMETRY_DISABLED=1 +RUN pnpm build + +FROM base AS runner +WORKDIR /app +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3010 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3010 +CMD ["node", "server.js"] diff --git a/landing/README.md b/landing/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/landing/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/landing/eslint.config.mjs b/landing/eslint.config.mjs new file mode 100644 index 0000000..05e726d --- /dev/null +++ b/landing/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/landing/next.config.ts b/landing/next.config.ts new file mode 100644 index 0000000..68a6c64 --- /dev/null +++ b/landing/next.config.ts @@ -0,0 +1,7 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", +}; + +export default nextConfig; diff --git a/landing/package.json b/landing/package.json new file mode 100644 index 0000000..ccd9307 --- /dev/null +++ b/landing/package.json @@ -0,0 +1,35 @@ +{ + "name": "landing", + "version": "0.1.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@auth/core": "^0.34.3", + "@heroicons/react": "^2.2.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "framer-motion": "^12.38.0", + "lucide-react": "^1.14.0", + "next": "16.2.4", + "next-auth": "5.0.0-beta.31", + "next-themes": "^0.4.6", + "react": "19.2.4", + "react-dom": "19.2.4", + "tailwind-merge": "^3.5.0" + }, + "devDependencies": { + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.2.4", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/landing/pnpm-lock.yaml b/landing/pnpm-lock.yaml new file mode 100644 index 0000000..1b3c337 --- /dev/null +++ b/landing/pnpm-lock.yaml @@ -0,0 +1,4282 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@auth/core': + specifier: ^0.34.3 + version: 0.34.3 + '@heroicons/react': + specifier: ^2.2.0 + version: 2.2.0(react@19.2.4) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + framer-motion: + specifier: ^12.38.0 + version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + lucide-react: + specifier: ^1.14.0 + version: 1.14.0(react@19.2.4) + next: + specifier: 16.2.4 + version: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-auth: + specifier: 5.0.0-beta.31 + version: 5.0.0-beta.31(next@16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: + specifier: 19.2.4 + version: 19.2.4 + react-dom: + specifier: 19.2.4 + version: 19.2.4(react@19.2.4) + tailwind-merge: + specifier: ^3.5.0 + version: 3.5.0 + devDependencies: + '@tailwindcss/postcss': + specifier: ^4 + version: 4.2.4 + '@types/node': + specifier: ^20 + version: 20.19.39 + '@types/react': + specifier: ^19 + version: 19.2.14 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.14) + eslint: + specifier: ^9 + version: 9.39.4(jiti@2.6.1) + eslint-config-next: + specifier: 16.2.4 + version: 16.2.4(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + tailwindcss: + specifier: ^4 + version: 4.2.4 + typescript: + specifier: ^5 + version: 5.9.3 + +packages: + + '@alloc/quick-lru@5.2.0': + resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} + engines: {node: '>=10'} + + '@auth/core@0.34.3': + resolution: {integrity: sha512-jMjY/S0doZnWYNV90x0jmU3B+UcrsfGYnukxYrRbj0CVvGI/MX3JbHsxSrx2d4mbnXaUsqJmAcDfoQWA6r0lOw==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^7 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + + '@auth/core@0.41.2': + resolution: {integrity: sha512-Hx5MNBxN2fJTbJKGUKAA0wca43D0Akl3TvufY54Gn8lop7F+34vU1zA1pn0vQfIoVuLIrpfc2nkyjwIaPJMW7w==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + nodemailer: ^7.0.7 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.3': + resolution: {integrity: sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.21.2': + resolution: {integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/config-helpers@0.4.2': + resolution: {integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/core@0.17.0': + resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/eslintrc@3.3.5': + resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/js@9.39.4': + resolution: {integrity: sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/object-schema@2.1.7': + resolution: {integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@eslint/plugin-kit@0.4.1': + resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@heroicons/react@2.2.0': + resolution: {integrity: sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==} + peerDependencies: + react: '>= 16 || ^19.0.0-rc' + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@0.2.12': + resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + + '@next/env@16.2.4': + resolution: {integrity: sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==} + + '@next/eslint-plugin-next@16.2.4': + resolution: {integrity: sha512-tOX826JJ96gYK/go18sPUgMq9FK1tqxBFfUCEufJb5XIkWFFmpgU7mahJANKGkHs7F41ir3tReJ3Lv5La0RvhA==} + + '@next/swc-darwin-arm64@16.2.4': + resolution: {integrity: sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.4': + resolution: {integrity: sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.4': + resolution: {integrity: sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.2.4': + resolution: {integrity: sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.2.4': + resolution: {integrity: sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.2.4': + resolution: {integrity: sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.2.4': + resolution: {integrity: sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.4': + resolution: {integrity: sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + + '@panva/hkdf@1.2.1': + resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==} + + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.2.4': + resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} + + '@tailwindcss/oxide-android-arm64@4.2.4': + resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.4': + resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.4': + resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.4': + resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.4': + resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} + engines: {node: '>= 20'} + + '@tailwindcss/postcss@4.2.4': + resolution: {integrity: sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/json5@0.0.29': + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + + '@types/node@20.19.39': + resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.14': + resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + + '@typescript-eslint/eslint-plugin@8.59.2': + resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.59.2 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.59.2': + resolution: {integrity: sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.59.2': + resolution: {integrity: sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.59.2': + resolution: {integrity: sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.59.2': + resolution: {integrity: sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.59.2': + resolution: {integrity: sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.59.2': + resolution: {integrity: sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.59.2': + resolution: {integrity: sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.59.2': + resolution: {integrity: sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.59.2': + resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + cpu: [arm] + os: [android] + + '@unrs/resolver-binding-android-arm64@1.11.1': + resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + cpu: [arm64] + os: [android] + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + cpu: [arm64] + os: [darwin] + + '@unrs/resolver-binding-darwin-x64@1.11.1': + resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + cpu: [x64] + os: [darwin] + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + cpu: [x64] + os: [freebsd] + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + cpu: [arm] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + cpu: [arm64] + os: [linux] + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + cpu: [ppc64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + cpu: [riscv64] + os: [linux] + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + cpu: [s390x] + os: [linux] + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + cpu: [x64] + os: [linux] + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + cpu: [arm64] + os: [win32] + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + cpu: [ia32] + os: [win32] + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + cpu: [x64] + os: [win32] + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-buffer-byte-length@1.0.2: + resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} + engines: {node: '>= 0.4'} + + array-includes@3.1.9: + resolution: {integrity: sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + + array.prototype.findlastindex@1.2.6: + resolution: {integrity: sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==} + engines: {node: '>= 0.4'} + + array.prototype.flat@1.3.3: + resolution: {integrity: sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==} + engines: {node: '>= 0.4'} + + array.prototype.flatmap@1.3.3: + resolution: {integrity: sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==} + engines: {node: '>= 0.4'} + + array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + + arraybuffer.prototype.slice@1.0.4: + resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} + engines: {node: '>= 0.4'} + + ast-types-flow@0.0.8: + resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} + + async-function@1.0.0: + resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} + engines: {node: '>= 0.4'} + + available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + + axe-core@4.11.4: + resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} + engines: {node: '>=4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.27: + resolution: {integrity: sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001791: + resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + + chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + damerau-levenshtein@1.0.8: + resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} + + data-view-buffer@1.0.2: + resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} + engines: {node: '>= 0.4'} + + data-view-byte-length@1.0.2: + resolution: {integrity: sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==} + engines: {node: '>= 0.4'} + + data-view-byte-offset@1.0.1: + resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} + engines: {node: '>= 0.4'} + + debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + + define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.349: + resolution: {integrity: sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} + engines: {node: '>=10.13.0'} + + es-abstract@1.24.2: + resolution: {integrity: sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==} + engines: {node: '>= 0.4'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-iterator-helpers@1.3.2: + resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + es-shim-unscopables@1.1.0: + resolution: {integrity: sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==} + engines: {node: '>= 0.4'} + + es-to-primitive@1.3.0: + resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-config-next@16.2.4: + resolution: {integrity: sha512-A6ekXYFj/YQxBPMl45g3e+U8zJo+X2+ZQwcz34pPKjpc/3S4roBA2Rd9xWB4FKuSxhofo1/95WjzmUY+wHrOhg==} + peerDependencies: + eslint: '>=9.0.0' + typescript: '>=3.3.1' + peerDependenciesMeta: + typescript: + optional: true + + eslint-import-resolver-node@0.3.10: + resolution: {integrity: sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==} + + eslint-import-resolver-typescript@3.10.1: + resolution: {integrity: sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + eslint: '*' + eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true + + eslint-module-utils@2.12.1: + resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-import@2.32.0: + resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + + eslint-plugin-jsx-a11y@6.10.2: + resolution: {integrity: sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==} + engines: {node: '>=4.0'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9 + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react@7.37.5: + resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + + eslint-scope@8.4.0: + resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@4.2.1: + resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@9.39.4: + resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.1: + resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} + engines: {node: '>=8.6.0'} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + for-each@0.3.5: + resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} + engines: {node: '>= 0.4'} + + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + function.prototype.name@1.1.8: + resolution: {integrity: sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==} + engines: {node: '>= 0.4'} + + functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + + generator-function@2.0.1: + resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} + engines: {node: '>= 0.4'} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + get-symbol-description@1.1.0: + resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} + engines: {node: '>= 0.4'} + + get-tsconfig@4.14.0: + resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@14.0.0: + resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} + engines: {node: '>=18'} + + globals@16.4.0: + resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} + engines: {node: '>=18'} + + globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + has-bigints@1.1.0: + resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-proto@1.2.0: + resolution: {integrity: sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + internal-slot@1.1.0: + resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} + engines: {node: '>= 0.4'} + + is-array-buffer@3.0.5: + resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} + engines: {node: '>= 0.4'} + + is-async-function@2.1.1: + resolution: {integrity: sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==} + engines: {node: '>= 0.4'} + + is-bigint@1.1.0: + resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} + engines: {node: '>= 0.4'} + + is-boolean-object@1.2.2: + resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} + engines: {node: '>= 0.4'} + + is-bun-module@2.0.0: + resolution: {integrity: sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==} + + is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + + is-core-module@2.16.1: + resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} + engines: {node: '>= 0.4'} + + is-data-view@1.0.2: + resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} + engines: {node: '>= 0.4'} + + is-date-object@1.1.0: + resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} + engines: {node: '>= 0.4'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-finalizationregistry@1.1.1: + resolution: {integrity: sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==} + engines: {node: '>= 0.4'} + + is-generator-function@1.1.2: + resolution: {integrity: sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==} + engines: {node: '>= 0.4'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + + is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + + is-number-object@1.1.1: + resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} + engines: {node: '>= 0.4'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-regex@1.2.1: + resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} + engines: {node: '>= 0.4'} + + is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + + is-shared-array-buffer@1.0.4: + resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==} + engines: {node: '>= 0.4'} + + is-string@1.1.1: + resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==} + engines: {node: '>= 0.4'} + + is-symbol@1.1.1: + resolution: {integrity: sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==} + engines: {node: '>= 0.4'} + + is-typed-array@1.1.15: + resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} + engines: {node: '>= 0.4'} + + is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + + is-weakref@1.1.1: + resolution: {integrity: sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==} + engines: {node: '>= 0.4'} + + is-weakset@2.0.4: + resolution: {integrity: sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==} + engines: {node: '>= 0.4'} + + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + iterator.prototype@1.1.5: + resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} + engines: {node: '>= 0.4'} + + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + + jose@5.10.0: + resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + language-subtag-registry@0.3.23: + resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} + + language-tags@1.0.9: + resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} + engines: {node: '>=0.10'} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lucide-react@1.14.0: + resolution: {integrity: sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minimatch@3.1.5: + resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + napi-postinstall@0.3.4: + resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==} + engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + next-auth@5.0.0-beta.31: + resolution: {integrity: sha512-1OBgCKPzo+S7UWWMp3xgvGvIJ0OpV7B3vR4ZDRqD9a4Ch+OT6dakLXG9ivhtmIWVa71nTSXattOHyCg8sNi8/Q==} + peerDependencies: + '@simplewebauthn/browser': ^9.0.1 + '@simplewebauthn/server': ^9.0.2 + next: ^14.0.0-0 || ^15.0.0 || ^16.0.0 + nodemailer: ^7.0.7 + react: ^18.2.0 || ^19.0.0 + peerDependenciesMeta: + '@simplewebauthn/browser': + optional: true + '@simplewebauthn/server': + optional: true + nodemailer: + optional: true + + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + + next@16.2.4: + resolution: {integrity: sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + + node-exports-info@1.6.0: + resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} + engines: {node: '>= 0.4'} + + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + + oauth4webapi@2.17.0: + resolution: {integrity: sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==} + + oauth4webapi@3.8.6: + resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + object.assign@4.1.7: + resolution: {integrity: sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==} + engines: {node: '>= 0.4'} + + object.entries@1.1.9: + resolution: {integrity: sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==} + engines: {node: '>= 0.4'} + + object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + + object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + + object.values@1.2.1: + resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} + engines: {node: '>= 0.4'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + own-keys@1.0.1: + resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} + engines: {node: '>= 0.4'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + possible-typed-array-names@1.1.0: + resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} + engines: {node: '>= 0.4'} + + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + + postcss@8.5.14: + resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} + engines: {node: ^10 || ^12 || >=14} + + preact-render-to-string@5.2.3: + resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} + peerDependencies: + preact: '>=10' + + preact-render-to-string@6.5.11: + resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==} + peerDependencies: + preact: '>=10' + + preact@10.11.3: + resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==} + + preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + pretty-format@3.8.0: + resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.4: + resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} + peerDependencies: + react: ^19.2.4 + + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react@19.2.4: + resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} + engines: {node: '>=0.10.0'} + + reflect.getprototypeof@1.0.10: + resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} + engines: {node: '>= 0.4'} + + regexp.prototype.flags@1.5.4: + resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} + engines: {node: '>= 0.4'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + resolve@2.0.0-next.6: + resolution: {integrity: sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==} + engines: {node: '>= 0.4'} + hasBin: true + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + safe-array-concat@1.1.4: + resolution: {integrity: sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==} + engines: {node: '>=0.4'} + + safe-push-apply@1.0.0: + resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} + engines: {node: '>= 0.4'} + + safe-regex-test@1.1.0: + resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} + engines: {node: '>= 0.4'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + + set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + + set-proto@1.0.0: + resolution: {integrity: sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==} + engines: {node: '>= 0.4'} + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stable-hash@0.0.5: + resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + + stop-iteration-iterator@1.1.0: + resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} + engines: {node: '>= 0.4'} + + string.prototype.includes@2.0.1: + resolution: {integrity: sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==} + engines: {node: '>= 0.4'} + + string.prototype.matchall@4.0.12: + resolution: {integrity: sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==} + engines: {node: '>= 0.4'} + + string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + + string.prototype.trim@1.2.10: + resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} + engines: {node: '>= 0.4'} + + string.prototype.trimend@1.0.9: + resolution: {integrity: sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==} + engines: {node: '>= 0.4'} + + string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + + strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + + strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.4: + resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typed-array-buffer@1.0.3: + resolution: {integrity: sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==} + engines: {node: '>= 0.4'} + + typed-array-byte-length@1.0.3: + resolution: {integrity: sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==} + engines: {node: '>= 0.4'} + + typed-array-byte-offset@1.0.4: + resolution: {integrity: sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==} + engines: {node: '>= 0.4'} + + typed-array-length@1.0.7: + resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} + engines: {node: '>= 0.4'} + + typescript-eslint@8.59.2: + resolution: {integrity: sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + unbox-primitive@1.1.0: + resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} + engines: {node: '>= 0.4'} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unrs-resolver@1.11.1: + resolution: {integrity: sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + which-boxed-primitive@1.1.1: + resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} + engines: {node: '>= 0.4'} + + which-builtin-type@1.2.1: + resolution: {integrity: sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==} + engines: {node: '>= 0.4'} + + which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + + which-typed-array@1.1.20: + resolution: {integrity: sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==} + engines: {node: '>= 0.4'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@alloc/quick-lru@5.2.0': {} + + '@auth/core@0.34.3': + dependencies: + '@panva/hkdf': 1.2.1 + '@types/cookie': 0.6.0 + cookie: 0.6.0 + jose: 5.10.0 + oauth4webapi: 2.17.0 + preact: 10.11.3 + preact-render-to-string: 5.2.3(preact@10.11.3) + + '@auth/core@0.41.2': + dependencies: + '@panva/hkdf': 1.2.1 + jose: 6.2.3 + oauth4webapi: 3.8.6 + preact: 10.24.3 + preact-render-to-string: 6.5.11(preact@10.24.3) + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.3': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.3 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.3 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.3 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@9.39.4(jiti@2.6.1))': + dependencies: + eslint: 9.39.4(jiti@2.6.1) + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.21.2': + dependencies: + '@eslint/object-schema': 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.4.2': + dependencies: + '@eslint/core': 0.17.0 + + '@eslint/core@0.17.0': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/eslintrc@3.3.5': + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + '@eslint/js@9.39.4': {} + + '@eslint/object-schema@2.1.7': {} + + '@eslint/plugin-kit@0.4.1': + dependencies: + '@eslint/core': 0.17.0 + levn: 0.4.1 + + '@heroicons/react@2.2.0(react@19.2.4)': + dependencies: + react: 19.2.4 + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@next/env@16.2.4': {} + + '@next/eslint-plugin-next@16.2.4': + dependencies: + fast-glob: 3.3.1 + + '@next/swc-darwin-arm64@16.2.4': + optional: true + + '@next/swc-darwin-x64@16.2.4': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.4': + optional: true + + '@next/swc-linux-arm64-musl@16.2.4': + optional: true + + '@next/swc-linux-x64-gnu@16.2.4': + optional: true + + '@next/swc-linux-x64-musl@16.2.4': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.4': + optional: true + + '@next/swc-win32-x64-msvc@16.2.4': + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@nolyfill/is-core-module@1.0.39': {} + + '@panva/hkdf@1.2.1': {} + + '@rtsao/scc@1.1.0': {} + + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.4': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.0 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.4 + + '@tailwindcss/oxide-android-arm64@4.2.4': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.4': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.4': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + optional: true + + '@tailwindcss/oxide@4.2.4': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-x64': 4.2.4 + '@tailwindcss/oxide-freebsd-x64': 4.2.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-x64-musl': 4.2.4 + '@tailwindcss/oxide-wasm32-wasi': 4.2.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 + + '@tailwindcss/postcss@4.2.4': + dependencies: + '@alloc/quick-lru': 5.2.0 + '@tailwindcss/node': 4.2.4 + '@tailwindcss/oxide': 4.2.4 + postcss: 8.5.14 + tailwindcss: 4.2.4 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/cookie@0.6.0': {} + + '@types/estree@1.0.8': {} + + '@types/json-schema@7.0.15': {} + + '@types/json5@0.0.29': {} + + '@types/node@20.19.39': + dependencies: + undici-types: 6.21.0 + + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + + '@types/react@19.2.14': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + eslint: 9.39.4(jiti@2.6.1) + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + + '@typescript-eslint/tsconfig-utils@8.59.2(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + + '@typescript-eslint/type-utils@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.59.2': {} + + '@typescript-eslint/typescript-estree@8.59.2(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.59.2(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.59.2(typescript@5.9.3) + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/visitor-keys': 8.59.2 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.7.4 + tinyglobby: 0.2.16 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.59.2 + '@typescript-eslint/types': 8.59.2 + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.59.2': + dependencies: + '@typescript-eslint/types': 8.59.2 + eslint-visitor-keys: 5.0.1 + + '@unrs/resolver-binding-android-arm-eabi@1.11.1': + optional: true + + '@unrs/resolver-binding-android-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-arm64@1.11.1': + optional: true + + '@unrs/resolver-binding-darwin-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-freebsd-x64@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-arm64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-gnu@1.11.1': + optional: true + + '@unrs/resolver-binding-linux-x64-musl@1.11.1': + optional: true + + '@unrs/resolver-binding-wasm32-wasi@1.11.1': + dependencies: + '@napi-rs/wasm-runtime': 0.2.12 + optional: true + + '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': + optional: true + + '@unrs/resolver-binding-win32-x64-msvc@1.11.1': + optional: true + + acorn-jsx@5.3.2(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + + acorn@8.16.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-buffer-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + is-array-buffer: 3.0.5 + + array-includes@3.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + is-string: 1.1.1 + math-intrinsics: 1.1.0 + + array.prototype.findlast@1.2.5: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.findlastindex@1.2.6: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-shim-unscopables: 1.1.0 + + array.prototype.flat@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.flatmap@1.3.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-shim-unscopables: 1.1.0 + + array.prototype.tosorted@1.1.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-shim-unscopables: 1.1.0 + + arraybuffer.prototype.slice@1.0.4: + dependencies: + array-buffer-byte-length: 1.0.2 + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + is-array-buffer: 3.0.5 + + ast-types-flow@0.0.8: {} + + async-function@1.0.0: {} + + available-typed-arrays@1.0.7: + dependencies: + possible-typed-array-names: 1.1.0 + + axe-core@4.11.4: {} + + axobject-query@4.1.0: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.27: {} + + brace-expansion@1.1.14: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.27 + caniuse-lite: 1.0.30001791 + electron-to-chromium: 1.5.349 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001791: {} + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + + client-only@0.0.1: {} + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + convert-source-map@2.0.0: {} + + cookie@0.6.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + damerau-levenshtein@1.0.8: {} + + data-view-buffer@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-length@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + data-view-byte-offset@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-data-view: 1.0.2 + + debug@3.2.7: + dependencies: + ms: 2.1.3 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + + define-properties@1.2.1: + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + detect-libc@2.1.2: {} + + doctrine@2.1.0: + dependencies: + esutils: 2.0.3 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.349: {} + + emoji-regex@9.2.2: {} + + enhanced-resolve@5.21.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + es-abstract@1.24.2: + dependencies: + array-buffer-byte-length: 1.0.2 + arraybuffer.prototype.slice: 1.0.4 + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + data-view-buffer: 1.0.2 + data-view-byte-length: 1.0.2 + data-view-byte-offset: 1.0.1 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + es-set-tostringtag: 2.1.0 + es-to-primitive: 1.3.0 + function.prototype.name: 1.1.8 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + get-symbol-description: 1.1.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + internal-slot: 1.1.0 + is-array-buffer: 3.0.5 + is-callable: 1.2.7 + is-data-view: 1.0.2 + is-negative-zero: 2.0.3 + is-regex: 1.2.1 + is-set: 2.0.3 + is-shared-array-buffer: 1.0.4 + is-string: 1.1.1 + is-typed-array: 1.1.15 + is-weakref: 1.1.1 + math-intrinsics: 1.1.0 + object-inspect: 1.13.4 + object-keys: 1.1.1 + object.assign: 4.1.7 + own-keys: 1.0.1 + regexp.prototype.flags: 1.5.4 + safe-array-concat: 1.1.4 + safe-push-apply: 1.0.0 + safe-regex-test: 1.1.0 + set-proto: 1.0.0 + stop-iteration-iterator: 1.1.0 + string.prototype.trim: 1.2.10 + string.prototype.trimend: 1.0.9 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.3 + typed-array-byte-length: 1.0.3 + typed-array-byte-offset: 1.0.4 + typed-array-length: 1.0.7 + unbox-primitive: 1.1.0 + which-typed-array: 1.1.20 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-iterator-helpers@1.3.2: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-set-tostringtag: 2.1.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + globalthis: 1.0.4 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + has-proto: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + iterator.prototype: 1.1.5 + math-intrinsics: 1.1.0 + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + es-shim-unscopables@1.1.0: + dependencies: + hasown: 2.0.3 + + es-to-primitive@1.3.0: + dependencies: + is-callable: 1.2.7 + is-date-object: 1.1.0 + is-symbol: 1.1.1 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-config-next@16.2.4(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@next/eslint-plugin-next': 16.2.4 + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-react-hooks: 7.1.1(eslint@9.39.4(jiti@2.6.1)) + globals: 16.4.0 + typescript-eslint: 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-webpack + - eslint-plugin-import-x + - supports-color + + eslint-import-resolver-node@0.3.10: + dependencies: + debug: 3.2.7 + is-core-module: 2.16.1 + resolve: 2.0.0-next.6 + transitivePeerDependencies: + - supports-color + + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@nolyfill/is-core-module': 1.0.39 + debug: 4.4.3 + eslint: 9.39.4(jiti@2.6.1) + get-tsconfig: 4.14.0 + is-bun-module: 2.0.0 + stable-hash: 0.0.5 + tinyglobby: 0.2.16 + unrs-resolver: 1.11.1 + optionalDependencies: + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.10 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.4(jiti@2.6.1)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@rtsao/scc': 1.1.0 + array-includes: 3.1.9 + array.prototype.findlastindex: 1.2.6 + array.prototype.flat: 1.3.3 + array.prototype.flatmap: 1.3.3 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 9.39.4(jiti@2.6.1) + eslint-import-resolver-node: 0.3.10 + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.10)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) + hasown: 2.0.3 + is-core-module: 2.16.1 + is-glob: 4.0.3 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.1 + semver: 6.3.1 + string.prototype.trimend: 1.0.9 + tsconfig-paths: 3.15.0 + optionalDependencies: + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + + eslint-plugin-jsx-a11y@6.10.2(eslint@9.39.4(jiti@2.6.1)): + dependencies: + aria-query: 5.3.2 + array-includes: 3.1.9 + array.prototype.flatmap: 1.3.3 + ast-types-flow: 0.0.8 + axe-core: 4.11.4 + axobject-query: 4.1.0 + damerau-levenshtein: 1.0.8 + emoji-regex: 9.2.2 + eslint: 9.39.4(jiti@2.6.1) + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + language-tags: 1.0.9 + minimatch: 3.1.5 + object.fromentries: 2.0.8 + safe-regex-test: 1.1.0 + string.prototype.includes: 2.0.1 + + eslint-plugin-react-hooks@7.1.1(eslint@9.39.4(jiti@2.6.1)): + dependencies: + '@babel/core': 7.29.0 + '@babel/parser': 7.29.3 + eslint: 9.39.4(jiti@2.6.1) + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react@7.37.5(eslint@9.39.4(jiti@2.6.1)): + dependencies: + array-includes: 3.1.9 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.3 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.3.2 + eslint: 9.39.4(jiti@2.6.1) + estraverse: 5.3.0 + hasown: 2.0.3 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.5 + object.entries: 1.1.9 + object.fromentries: 2.0.8 + object.values: 1.2.1 + prop-types: 15.8.1 + resolve: 2.0.0-next.6 + semver: 6.3.1 + string.prototype.matchall: 4.0.12 + string.prototype.repeat: 1.0.0 + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.4(jiti@2.6.1): + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.21.2 + '@eslint/config-helpers': 0.4.2 + '@eslint/core': 0.17.0 + '@eslint/eslintrc': 3.3.5 + '@eslint/js': 9.39.4 + '@eslint/plugin-kit': 0.4.1 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.8 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + optionalDependencies: + jiti: 2.6.1 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.16.0 + acorn-jsx: 5.3.2(acorn@8.16.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.1: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + for-each@0.3.5: + dependencies: + is-callable: 1.2.7 + + framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + function-bind@1.1.2: {} + + function.prototype.name@1.1.8: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + functions-have-names: 1.2.3 + hasown: 2.0.3 + is-callable: 1.2.7 + + functions-have-names@1.2.3: {} + + generator-function@2.0.1: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + get-symbol-description@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + + get-tsconfig@4.14.0: + dependencies: + resolve-pkg-maps: 1.0.0 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.4.0: {} + + globalthis@1.0.4: + dependencies: + define-properties: 1.2.1 + gopd: 1.2.0 + + gopd@1.2.0: {} + + graceful-fs@4.2.11: {} + + has-bigints@1.1.0: {} + + has-flag@4.0.0: {} + + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-proto@1.2.0: + dependencies: + dunder-proto: 1.0.1 + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + internal-slot@1.1.0: + dependencies: + es-errors: 1.3.0 + hasown: 2.0.3 + side-channel: 1.1.0 + + is-array-buffer@3.0.5: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + is-async-function@2.1.1: + dependencies: + async-function: 1.0.0 + call-bound: 1.0.4 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-bigint@1.1.0: + dependencies: + has-bigints: 1.1.0 + + is-boolean-object@1.2.2: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-bun-module@2.0.0: + dependencies: + semver: 7.7.4 + + is-callable@1.2.7: {} + + is-core-module@2.16.1: + dependencies: + hasown: 2.0.3 + + is-data-view@1.0.2: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + is-typed-array: 1.1.15 + + is-date-object@1.1.0: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-extglob@2.1.1: {} + + is-finalizationregistry@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-generator-function@1.1.2: + dependencies: + call-bound: 1.0.4 + generator-function: 2.0.1 + get-proto: 1.0.1 + has-tostringtag: 1.0.2 + safe-regex-test: 1.1.0 + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-map@2.0.3: {} + + is-negative-zero@2.0.3: {} + + is-number-object@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-number@7.0.0: {} + + is-regex@1.2.1: + dependencies: + call-bound: 1.0.4 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + hasown: 2.0.3 + + is-set@2.0.3: {} + + is-shared-array-buffer@1.0.4: + dependencies: + call-bound: 1.0.4 + + is-string@1.1.1: + dependencies: + call-bound: 1.0.4 + has-tostringtag: 1.0.2 + + is-symbol@1.1.1: + dependencies: + call-bound: 1.0.4 + has-symbols: 1.1.0 + safe-regex-test: 1.1.0 + + is-typed-array@1.1.15: + dependencies: + which-typed-array: 1.1.20 + + is-weakmap@2.0.2: {} + + is-weakref@1.1.1: + dependencies: + call-bound: 1.0.4 + + is-weakset@2.0.4: + dependencies: + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + + isarray@2.0.5: {} + + isexe@2.0.0: {} + + iterator.prototype@1.1.5: + dependencies: + define-data-property: 1.1.4 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + has-symbols: 1.1.0 + set-function-name: 2.0.2 + + jiti@2.6.1: {} + + jose@5.10.0: {} + + jose@6.2.3: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@1.0.2: + dependencies: + minimist: 1.2.8 + + json5@2.2.3: {} + + jsx-ast-utils@3.3.5: + dependencies: + array-includes: 3.1.9 + array.prototype.flat: 1.3.3 + object.assign: 4.1.7 + object.values: 1.2.1 + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + language-subtag-registry@0.3.23: {} + + language-tags@1.0.9: + dependencies: + language-subtag-registry: 0.3.23 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lucide-react@1.14.0(react@19.2.4): + dependencies: + react: 19.2.4 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.2 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.14 + + minimist@1.2.8: {} + + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + + motion-utils@12.36.0: {} + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + napi-postinstall@0.3.4: {} + + natural-compare@1.4.0: {} + + next-auth@5.0.0-beta.31(next@16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4): + dependencies: + '@auth/core': 0.41.2 + next: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + + next@16.2.4(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@next/env': 16.2.4 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.27 + caniuse-lite: 1.0.30001791 + postcss: 8.4.31 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.4 + '@next/swc-darwin-x64': 16.2.4 + '@next/swc-linux-arm64-gnu': 16.2.4 + '@next/swc-linux-arm64-musl': 16.2.4 + '@next/swc-linux-x64-gnu': 16.2.4 + '@next/swc-linux-x64-musl': 16.2.4 + '@next/swc-win32-arm64-msvc': 16.2.4 + '@next/swc-win32-x64-msvc': 16.2.4 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + + node-exports-info@1.6.0: + dependencies: + array.prototype.flatmap: 1.3.3 + es-errors: 1.3.0 + object.entries: 1.1.9 + semver: 6.3.1 + + node-releases@2.0.38: {} + + oauth4webapi@2.17.0: {} + + oauth4webapi@3.8.6: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + object-keys@1.1.1: {} + + object.assign@4.1.7: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + has-symbols: 1.1.0 + object-keys: 1.1.1 + + object.entries@1.1.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + object.fromentries@2.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + + object.groupby@1.0.3: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + object.values@1.2.1: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + own-keys@1.0.1: + dependencies: + get-intrinsic: 1.3.0 + object-keys: 1.1.1 + safe-push-apply: 1.0.0 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + path-parse@1.0.7: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.4: {} + + possible-typed-array-names@1.1.0: {} + + postcss@8.4.31: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + postcss@8.5.14: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preact-render-to-string@5.2.3(preact@10.11.3): + dependencies: + preact: 10.11.3 + pretty-format: 3.8.0 + + preact-render-to-string@6.5.11(preact@10.24.3): + dependencies: + preact: 10.24.3 + + preact@10.11.3: {} + + preact@10.24.3: {} + + prelude-ls@1.2.1: {} + + pretty-format@3.8.0: {} + + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + punycode@2.3.1: {} + + queue-microtask@1.2.3: {} + + react-dom@19.2.4(react@19.2.4): + dependencies: + react: 19.2.4 + scheduler: 0.27.0 + + react-is@16.13.1: {} + + react@19.2.4: {} + + reflect.getprototypeof@1.0.10: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + get-proto: 1.0.1 + which-builtin-type: 1.2.1 + + regexp.prototype.flags@1.5.4: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-errors: 1.3.0 + get-proto: 1.0.1 + gopd: 1.2.0 + set-function-name: 2.0.2 + + resolve-from@4.0.0: {} + + resolve-pkg-maps@1.0.0: {} + + resolve@2.0.0-next.6: + dependencies: + es-errors: 1.3.0 + is-core-module: 2.16.1 + node-exports-info: 1.6.0 + object-keys: 1.1.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + reusify@1.1.0: {} + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + safe-array-concat@1.1.4: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + get-intrinsic: 1.3.0 + has-symbols: 1.1.0 + isarray: 2.0.5 + + safe-push-apply@1.0.0: + dependencies: + es-errors: 1.3.0 + isarray: 2.0.5 + + safe-regex-test@1.1.0: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-regex: 1.2.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.7.4: {} + + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + + set-function-name@2.0.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + + set-proto@1.0.0: + dependencies: + dunder-proto: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + source-map-js@1.2.1: {} + + stable-hash@0.0.5: {} + + stop-iteration-iterator@1.1.0: + dependencies: + es-errors: 1.3.0 + internal-slot: 1.1.0 + + string.prototype.includes@2.0.1: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.matchall@4.0.12: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-symbols: 1.1.0 + internal-slot: 1.1.0 + regexp.prototype.flags: 1.5.4 + set-function-name: 2.0.2 + side-channel: 1.1.0 + + string.prototype.repeat@1.0.0: + dependencies: + define-properties: 1.2.1 + es-abstract: 1.24.2 + + string.prototype.trim@1.2.10: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-data-property: 1.1.4 + define-properties: 1.2.1 + es-abstract: 1.24.2 + es-object-atoms: 1.1.1 + has-property-descriptors: 1.0.2 + + string.prototype.trimend@1.0.9: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + string.prototype.trimstart@1.0.8: + dependencies: + call-bind: 1.0.9 + define-properties: 1.2.1 + es-object-atoms: 1.1.1 + + strip-bom@3.0.0: {} + + strip-json-comments@3.1.1: {} + + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): + dependencies: + client-only: 0.0.1 + react: 19.2.4 + optionalDependencies: + '@babel/core': 7.29.0 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-preserve-symlinks-flag@1.0.0: {} + + tailwind-merge@3.5.0: {} + + tailwindcss@4.2.4: {} + + tapable@2.3.3: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tsconfig-paths@3.15.0: + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + + tslib@2.8.1: {} + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typed-array-buffer@1.0.3: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + is-typed-array: 1.1.15 + + typed-array-byte-length@1.0.3: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + + typed-array-byte-offset@1.0.4: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + has-proto: 1.2.0 + is-typed-array: 1.1.15 + reflect.getprototypeof: 1.0.10 + + typed-array-length@1.0.7: + dependencies: + call-bind: 1.0.9 + for-each: 0.3.5 + gopd: 1.2.0 + is-typed-array: 1.1.15 + possible-typed-array-names: 1.1.0 + reflect.getprototypeof: 1.0.10 + + typescript-eslint@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/typescript-estree': 8.59.2(typescript@5.9.3) + '@typescript-eslint/utils': 8.59.2(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + typescript@5.9.3: {} + + unbox-primitive@1.1.0: + dependencies: + call-bound: 1.0.4 + has-bigints: 1.1.0 + has-symbols: 1.1.0 + which-boxed-primitive: 1.1.1 + + undici-types@6.21.0: {} + + unrs-resolver@1.11.1: + dependencies: + napi-postinstall: 0.3.4 + optionalDependencies: + '@unrs/resolver-binding-android-arm-eabi': 1.11.1 + '@unrs/resolver-binding-android-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-arm64': 1.11.1 + '@unrs/resolver-binding-darwin-x64': 1.11.1 + '@unrs/resolver-binding-freebsd-x64': 1.11.1 + '@unrs/resolver-binding-linux-arm-gnueabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm-musleabihf': 1.11.1 + '@unrs/resolver-binding-linux-arm64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-arm64-musl': 1.11.1 + '@unrs/resolver-binding-linux-ppc64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-riscv64-musl': 1.11.1 + '@unrs/resolver-binding-linux-s390x-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-gnu': 1.11.1 + '@unrs/resolver-binding-linux-x64-musl': 1.11.1 + '@unrs/resolver-binding-wasm32-wasi': 1.11.1 + '@unrs/resolver-binding-win32-arm64-msvc': 1.11.1 + '@unrs/resolver-binding-win32-ia32-msvc': 1.11.1 + '@unrs/resolver-binding-win32-x64-msvc': 1.11.1 + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + which-boxed-primitive@1.1.1: + dependencies: + is-bigint: 1.1.0 + is-boolean-object: 1.2.2 + is-number-object: 1.1.1 + is-string: 1.1.1 + is-symbol: 1.1.1 + + which-builtin-type@1.2.1: + dependencies: + call-bound: 1.0.4 + function.prototype.name: 1.1.8 + has-tostringtag: 1.0.2 + is-async-function: 2.1.1 + is-date-object: 1.1.0 + is-finalizationregistry: 1.1.1 + is-generator-function: 1.1.2 + is-regex: 1.2.1 + is-weakref: 1.1.1 + isarray: 2.0.5 + which-boxed-primitive: 1.1.1 + which-collection: 1.0.2 + which-typed-array: 1.1.20 + + which-collection@1.0.2: + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.4 + + which-typed-array@1.1.20: + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.9 + call-bound: 1.0.4 + for-each: 0.3.5 + get-proto: 1.0.1 + gopd: 1.2.0 + has-tostringtag: 1.0.2 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/landing/pnpm-workspace.yaml b/landing/pnpm-workspace.yaml new file mode 100644 index 0000000..581a9d5 --- /dev/null +++ b/landing/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +ignoredBuiltDependencies: + - sharp + - unrs-resolver diff --git a/landing/postcss.config.mjs b/landing/postcss.config.mjs new file mode 100644 index 0000000..61e3684 --- /dev/null +++ b/landing/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/landing/public/file.svg b/landing/public/file.svg new file mode 100644 index 0000000..004145c --- /dev/null +++ b/landing/public/file.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/landing/public/globe.svg b/landing/public/globe.svg new file mode 100644 index 0000000..567f17b --- /dev/null +++ b/landing/public/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/landing/public/next.svg b/landing/public/next.svg new file mode 100644 index 0000000..5174b28 --- /dev/null +++ b/landing/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/landing/public/vercel.svg b/landing/public/vercel.svg new file mode 100644 index 0000000..7705396 --- /dev/null +++ b/landing/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/landing/public/window.svg b/landing/public/window.svg new file mode 100644 index 0000000..b2b2a44 --- /dev/null +++ b/landing/public/window.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/landing/src/app/api/auth/[...nextauth]/route.ts b/landing/src/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..86c9f3d --- /dev/null +++ b/landing/src/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/auth"; + +export const { GET, POST } = handlers; diff --git a/landing/src/app/favicon.ico b/landing/src/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/landing/src/app/favicon.ico differ diff --git a/landing/src/app/globals.css b/landing/src/app/globals.css new file mode 100644 index 0000000..6472a75 --- /dev/null +++ b/landing/src/app/globals.css @@ -0,0 +1,155 @@ +@import "tailwindcss"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + /* Dify / Untitled UI palette */ + --color-bg-primary: #ffffff; + --color-bg-secondary: #f9fafb; + --color-bg-tertiary: #f2f4f7; + --color-bg-section: #f6f7f9; + + --color-text-primary: #101828; + --color-text-secondary: #354052; + --color-text-tertiary: #676f83; + --color-text-quaternary: #98a2b2; + --color-text-disabled: #d0d5dc; + + --color-border-subtle: rgb(16 24 40 / 0.04); + --color-border-default: rgb(16 24 40 / 0.08); + --color-border-hover: rgb(16 24 40 / 0.14); + --color-border-strong: #d0d5dc; + + --color-card: #ffffff; + --color-card-hover: #f9fafb; + + /* Dify primary blue */ + --color-primary-50: #eff4ff; + --color-primary-100: #d1e0ff; + --color-primary-200: #b2caff; + --color-primary-500: #2970ff; + --color-primary-600: #155aef; + --color-primary-700: #004aeb; + --color-primary-800: #00359e; + + --color-primary: var(--color-primary-600); + --color-primary-hover: var(--color-primary-700); + --color-primary-foreground: #ffffff; + + /* Accent palette for tile icons (also re-declared in :root below so + Tailwind v4 doesn't tree-shake them — they're only used via inline + style in tile.tsx, which Tailwind's purger can't see). */ + --color-accent-blue: #155aef; + --color-accent-indigo: #444ce7; + --color-accent-violet: #7a5af8; + --color-accent-cyan: #0ba5ec; + --color-accent-emerald: #17b26a; + --color-accent-amber: #f79009; + --color-accent-rose: #f04438; + --color-accent-gray: #475467; + + --color-destructive: #d92d20; + --color-destructive-bg: #fef3f2; + --color-destructive-border: #fda29b; + + --radius-sm: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 16px; + + --shadow-card: 0 1px 2px rgb(16 24 40 / 0.05); + --shadow-card-hover: 0 12px 24px -8px rgb(16 24 40 / 0.12), 0 4px 8px -4px rgb(16 24 40 / 0.06); + + --font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; + --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace; +} + +/* Re-declare accents in :root so they survive Tailwind v4's purge of unused + @theme variables. Used by inline styles in tile.tsx (color-mix). */ +:root { + --color-accent-blue: #155aef; + --color-accent-indigo: #444ce7; + --color-accent-violet: #7a5af8; + --color-accent-cyan: #0ba5ec; + --color-accent-emerald: #17b26a; + --color-accent-amber: #f79009; + --color-accent-rose: #f04438; + --color-accent-gray: #475467; +} + +.dark { + --color-bg-primary: #14181f; + --color-bg-secondary: #1a1f29; + --color-bg-tertiary: #232936; + --color-bg-section: #181d26; + + --color-text-primary: #f5f7fa; + --color-text-secondary: #c8ccd5; + --color-text-tertiary: #98a2b2; + --color-text-quaternary: #676f83; + --color-text-disabled: #4a525e; + + --color-border-subtle: rgb(255 255 255 / 0.05); + --color-border-default: rgb(255 255 255 / 0.08); + --color-border-hover: rgb(255 255 255 / 0.14); + --color-border-strong: #354052; + + --color-card: #1a1f29; + --color-card-hover: #232936; + + --color-primary: var(--color-primary-500); + --color-primary-hover: #4187ff; + + --shadow-card: 0 1px 2px rgb(0 0 0 / 0.4); + --shadow-card-hover: 0 12px 24px -8px rgb(0 0 0 / 0.5), 0 4px 8px -4px rgb(0 0 0 / 0.3); +} + +html, +body { + height: 100%; +} + +body { + background: var(--color-bg-secondary); + color: var(--color-text-primary); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Dify-style typography utilities */ +.system-2xs-medium-uppercase { + font-size: 10px; + line-height: 14px; + font-weight: 500; + letter-spacing: 0.05em; + text-transform: uppercase; +} +.system-xs-regular { + font-size: 12px; + line-height: 16px; + font-weight: 400; +} +.system-xs-medium { + font-size: 12px; + line-height: 16px; + font-weight: 500; +} +.system-sm-medium { + font-size: 14px; + line-height: 20px; + font-weight: 500; +} +.system-sm-semibold { + font-size: 14px; + line-height: 20px; + font-weight: 600; +} + +/* Scrollbar polish */ +.no-scrollbar::-webkit-scrollbar { + display: none; +} +.no-scrollbar { + scrollbar-width: none; +} diff --git a/landing/src/app/layout.tsx b/landing/src/app/layout.tsx new file mode 100644 index 0000000..c03fff1 --- /dev/null +++ b/landing/src/app/layout.tsx @@ -0,0 +1,37 @@ +import type { Metadata } from "next"; +import { Geist, Geist_Mono } from "next/font/google"; +import { ThemeProvider } from "@/components/theme-provider"; +import "./globals.css"; + +const geistSans = Geist({ + variable: "--font-geist-sans", + subsets: ["latin", "latin-ext"], +}); + +const geistMono = Geist_Mono({ + variable: "--font-geist-mono", + subsets: ["latin", "latin-ext"], +}); + +export const metadata: Metadata = { + title: "Colsys AI", + description: "Interní AI portál společnosti Colsys.", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/landing/src/app/login/page.tsx b/landing/src/app/login/page.tsx new file mode 100644 index 0000000..7e57946 --- /dev/null +++ b/landing/src/app/login/page.tsx @@ -0,0 +1,114 @@ +import { redirect } from "next/navigation"; +import { auth, signIn } from "@/auth"; +import { Brand } from "@/components/brand"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; + +type SearchParams = Promise<{ callbackUrl?: string; error?: string }>; + +export default async function LoginPage({ + searchParams, +}: { + searchParams: SearchParams; +}) { + const { callbackUrl, error } = await searchParams; + + const session = await auth(); + if (session?.user) { + redirect(callbackUrl || "/"); + } + + const hasEntra = + !!process.env.AUTH_MICROSOFT_ENTRA_ID_ID && + !!process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET; + const hasDevPassword = !!process.env.DEV_PORTAL_PASSWORD; + + return ( +
+
+
+
+ +
+
+

+ Přihlášení +

+

+ Pokračujte přihlášením přes firemní účet. +

+ + {error ? ( +
+ Přihlášení se nezdařilo. Zkuste to prosím znovu. +
+ ) : null} + + {hasEntra ? ( +
{ + "use server"; + await signIn("microsoft-entra-id", { + redirectTo: callbackUrl || "/", + }); + }} + > + +
+ ) : null} + + {hasEntra && hasDevPassword ? ( +
+ + nebo + +
+ ) : null} + + {hasDevPassword ? ( +
{ + "use server"; + await signIn("dev", { + password: formData.get("password"), + redirectTo: callbackUrl || "/", + }); + }} + > + + + +
+ ) : null} + + {!hasEntra && !hasDevPassword ? ( +

+ Není nakonfigurován žádný poskytovatel přihlášení. Nastavte{" "} + DEV_PORTAL_PASSWORD{" "} + nebo Microsoft Entra v prostředí. +

+ ) : null} +
+ +

+ Interní AI portál společnosti Colsys +

+
+
+
+ ); +} diff --git a/landing/src/app/page.tsx b/landing/src/app/page.tsx new file mode 100644 index 0000000..1efc9e1 --- /dev/null +++ b/landing/src/app/page.tsx @@ -0,0 +1,33 @@ +import { Header } from "@/components/header"; +import { TileGrid } from "@/components/tile-grid"; +import apps from "@/data/apps.json"; +import type { AppTile } from "@/components/tile"; + +export default function Home() { + return ( +
+
+
+
+ + Interní AI portál + +

+ Vaše AI nástroje na jednom místě +

+

+ Vyberte si průvodce úlohou, nebo zahajte volný chat. Vše běží na + firemních klíčích — s rozpočty, sledováním nákladů a bez exportu + vašich dat ven. +

+
+ + +
+
+ Nevkládejte do AI nástrojů hesla ani neredaktované osobní či citlivé + údaje. +
+
+ ); +} diff --git a/landing/src/auth.ts b/landing/src/auth.ts new file mode 100644 index 0000000..aef8559 --- /dev/null +++ b/landing/src/auth.ts @@ -0,0 +1,51 @@ +import NextAuth, { type NextAuthConfig } from "next-auth"; +import Credentials from "next-auth/providers/credentials"; +import MicrosoftEntraID from "next-auth/providers/microsoft-entra-id"; + +const providers: NextAuthConfig["providers"] = []; + +if ( + process.env.AUTH_MICROSOFT_ENTRA_ID_ID && + process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET +) { + providers.push( + MicrosoftEntraID({ + clientId: process.env.AUTH_MICROSOFT_ENTRA_ID_ID, + clientSecret: process.env.AUTH_MICROSOFT_ENTRA_ID_SECRET, + issuer: process.env.AUTH_MICROSOFT_ENTRA_ID_ISSUER, + }), + ); +} + +if (process.env.DEV_PORTAL_PASSWORD) { + providers.push( + Credentials({ + id: "dev", + name: "Development access", + credentials: { + password: { label: "Access password", type: "password" }, + }, + authorize: async (credentials) => { + const supplied = + typeof credentials?.password === "string" ? credentials.password : ""; + if (supplied && supplied === process.env.DEV_PORTAL_PASSWORD) { + return { + id: "dev-user", + name: "Developer", + email: "dev@portal.local", + }; + } + return null; + }, + }), + ); +} + +export const { handlers, auth, signIn, signOut } = NextAuth({ + providers, + pages: { + signIn: "/login", + }, + session: { strategy: "jwt" }, + trustHost: true, +}); diff --git a/landing/src/components/brand.tsx b/landing/src/components/brand.tsx new file mode 100644 index 0000000..a1f9e29 --- /dev/null +++ b/landing/src/components/brand.tsx @@ -0,0 +1,32 @@ +import Link from "next/link"; + +export function Brand({ size = "default" }: { size?: "default" | "lg" }) { + const dim = size === "lg" ? "h-9 w-9" : "h-7 w-7"; + const text = size === "lg" ? "text-base" : "text-sm"; + + return ( + + + + C + + + + Colsys AI + + + ); +} diff --git a/landing/src/components/header.tsx b/landing/src/components/header.tsx new file mode 100644 index 0000000..4673ec6 --- /dev/null +++ b/landing/src/components/header.tsx @@ -0,0 +1,44 @@ +import { auth, signOut } from "@/auth"; +import { Brand } from "@/components/brand"; +import { Button } from "@/components/ui/button"; +import { ThemeToggle } from "@/components/theme-toggle"; +import Link from "next/link"; + +export async function Header() { + const session = await auth(); + const user = session?.user; + + return ( +
+
+ +
+ {user?.email ? ( + + {user.email} + + ) : null} + + {user ? ( +
{ + "use server"; + await signOut({ redirectTo: "/login" }); + }} + > + +
+ ) : ( + + + + )} +
+
+
+ ); +} diff --git a/landing/src/components/theme-provider.tsx b/landing/src/components/theme-provider.tsx new file mode 100644 index 0000000..b1130c2 --- /dev/null +++ b/landing/src/components/theme-provider.tsx @@ -0,0 +1,29 @@ +"use client"; + +import { ThemeProvider as NextThemesProvider, useTheme } from "next-themes"; +import { useEffect } from "react"; + +function CrossSubdomainThemeCookie() { + const { resolvedTheme } = useTheme(); + useEffect(() => { + if (!resolvedTheme) return; + // Cookie shared across *.klas.chat so sibling apps (e.g. dwg-counting) sync. + document.cookie = + `portal_theme=${resolvedTheme}; Path=/; Domain=.klas.chat; Max-Age=31536000; SameSite=Lax`; + }, [resolvedTheme]); + return null; +} + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ); +} diff --git a/landing/src/components/theme-toggle.tsx b/landing/src/components/theme-toggle.tsx new file mode 100644 index 0000000..b867f7a --- /dev/null +++ b/landing/src/components/theme-toggle.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { Moon, Sun } from "lucide-react"; +import { useTheme } from "next-themes"; +import { useEffect, useState } from "react"; +import { Button } from "@/components/ui/button"; + +export function ThemeToggle() { + const { resolvedTheme, setTheme } = useTheme(); + const [mounted, setMounted] = useState(false); + useEffect(() => setMounted(true), []); + + const isDark = mounted && resolvedTheme === "dark"; + + return ( + + ); +} diff --git a/landing/src/components/tile-grid.tsx b/landing/src/components/tile-grid.tsx new file mode 100644 index 0000000..c8e78ab --- /dev/null +++ b/landing/src/components/tile-grid.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { Search } from "lucide-react"; +import { Tile, type AppTile } from "@/components/tile"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; + +const ALL = "Vše"; + +export function TileGrid({ apps }: { apps: AppTile[] }) { + const [query, setQuery] = useState(""); + const [activeCategory, setActiveCategory] = useState(ALL); + + const categories = useMemo(() => { + const all = Array.from(new Set(apps.map((a) => a.category))); + return [ALL, ...all]; + }, [apps]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return apps.filter((a) => { + const matchesQuery = + !q || + a.title.toLowerCase().includes(q) || + a.description.toLowerCase().includes(q) || + a.category.toLowerCase().includes(q); + const matchesCategory = + activeCategory === ALL || a.category === activeCategory; + return matchesQuery && matchesCategory; + }); + }, [apps, query, activeCategory]); + + return ( +
+
+
+ + setQuery(e.target.value)} + className="pl-9" + /> +
+
+ {categories.map((cat) => { + const isActive = cat === activeCategory; + return ( + + ); + })} +
+
+ + {filtered.length === 0 ? ( +
+

+ Žádné aplikace neodpovídají vyhledávání. +

+
+ ) : ( +
+ {filtered.map((app) => ( + + ))} +
+ )} +
+ ); +} diff --git a/landing/src/components/tile.tsx b/landing/src/components/tile.tsx new file mode 100644 index 0000000..c71894e --- /dev/null +++ b/landing/src/components/tile.tsx @@ -0,0 +1,166 @@ +"use client"; + +import * as Icons from "lucide-react"; +import Link from "next/link"; +import { ArrowUpRight } from "lucide-react"; +import { useTheme } from "next-themes"; +import { cn } from "@/lib/utils"; + +export type AppTile = { + id: string; + title: string; + description: string; + category: string; + icon: string; + accent: string; + href: string; + featured?: boolean; + /** Open in a new tab. Defaults to false — task-style apps replace the + current tab so the browser back button works naturally. Set true for + long-running sessions like the chat tile. */ + newTab?: boolean; + /** Visual kind. "tool" (default) is a normal app tile. "cta" renders + a dashed-border invitation card visually distinct from the tools. */ + kind?: "tool" | "cta"; +}; + +const accentVar = (accent: string) => `var(--color-accent-${accent})`; + +export function Tile({ app }: { app: AppTile }) { + const { resolvedTheme } = useTheme(); + const LucideIcon = + (Icons[app.icon as keyof typeof Icons] as React.ComponentType<{ + className?: string; + }>) ?? Icons.Sparkles; + + const isExternal = app.href.startsWith("http"); + const isDisabled = app.href === "#"; + + // Append current theme to internal-app URLs so they pick up the same scheme. + const isInternalApp = isExternal && app.href.includes(".klas.chat"); + const hrefWithTheme = (() => { + if (!isInternalApp || !resolvedTheme) return app.href; + try { + const u = new URL(app.href); + u.searchParams.set("theme", resolvedTheme); + return u.toString(); + } catch { + return app.href; + } + })(); + + const isCta = app.kind === "cta"; + + const content = isCta ? ( + // ─── CTA / "request a new tool" — visually distinct ─── +
+
+ +
+
+

+ {app.title} +

+

+ {app.description} +

+
+
+ ) : ( + // ─── Standard tool tile ─── +
+
+
+ +
+
+
+

+ {app.title} +

+ {!isDisabled && ( + + )} +
+
+ {app.category} +
+
+
+ +
+

+ {app.description} +

+
+ + {isDisabled && ( +
+ + Brzy k dispozici + +
+ )} + + {!isDisabled && app.featured && ( +
+ + Doporučeno + +
+ )} +
+ ); + + if (isDisabled) { + return
{content}
; + } + + if (isExternal) { + const inNewTab = app.newTab === true; + return ( + + {content} + + ); + } + + return ( + + {content} + + ); +} diff --git a/landing/src/components/ui/button.tsx b/landing/src/components/ui/button.tsx new file mode 100644 index 0000000..590e2d9 --- /dev/null +++ b/landing/src/components/ui/button.tsx @@ -0,0 +1,48 @@ +import * as React from "react"; +import { cva, type VariantProps } from "class-variance-authority"; +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center gap-2 whitespace-nowrap font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-primary)] focus-visible:ring-offset-1 focus-visible:ring-offset-[var(--color-bg-primary)] disabled:pointer-events-none disabled:opacity-50", + { + variants: { + variant: { + primary: + "bg-[var(--color-primary)] text-[var(--color-primary-foreground)] border border-[var(--color-border-subtle)] hover:bg-[var(--color-primary-hover)] shadow-[var(--shadow-card)]", + secondary: + "bg-[var(--color-card)] text-[var(--color-text-secondary)] border border-[var(--color-border-hover)] hover:bg-[var(--color-bg-secondary)] hover:border-[var(--color-border-strong)]", + tertiary: + "bg-[var(--color-bg-tertiary)] text-[var(--color-text-secondary)] hover:bg-[color-mix(in_srgb,var(--color-bg-tertiary)_60%,var(--color-text-tertiary)_15%)]", + ghost: + "text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-tertiary)]", + }, + size: { + default: "h-9 px-4 text-sm rounded-lg", + sm: "h-8 px-3 text-xs rounded-md", + lg: "h-11 px-5 text-sm rounded-lg", + icon: "h-9 w-9 rounded-lg", + }, + }, + defaultVariants: { + variant: "primary", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +export const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => ( + + +
+ + +
+ +
+ + +
+ + + +
+
+
+ Zdrojový text +
+ 0 znaků + +
+
+ +
+ +
+
+ Překlad +
+ +
+
+ + +
+
+ +
+ + Vložte text výše. +
+ + + + + + + diff --git a/translator/static/styles.css b/translator/static/styles.css new file mode 100644 index 0000000..9efda74 --- /dev/null +++ b/translator/static/styles.css @@ -0,0 +1,461 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f2f4f7; + --text-primary: #101828; + --text-secondary: #354052; + --text-tertiary: #676f83; + --text-quaternary: #98a2b2; + --border-default: rgb(16 24 40 / 0.08); + --border-strong: #d0d5dc; + --border-subtle: rgb(16 24 40 / 0.04); + --card: #ffffff; + --primary: #155aef; + --primary-hover: #004aeb; + --accent-indigo: #444ce7; + --shadow-card: 0 1px 2px rgb(16 24 40 / 0.05); + --radius-md: 8px; + --radius-lg: 12px; +} + +/* Dark theme — applies when (a) user OS prefers dark and no .light override, + or (b) :root has explicit .dark class (set by portal_theme cookie). */ +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; + } +} +:root.dark { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; +} + +body { + font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 100vh; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Header ─────────────────────────────────────── */ +.header { + position: sticky; + top: 0; + z-index: 30; + border-bottom: 0.5px solid var(--border-default); + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + backdrop-filter: blur(8px); +} +.header-inner { + max-width: 1280px; /* match portal max-w-7xl so brand doesn't shift */ + margin: 0 auto; + height: 56px; /* portal uses h-14 */ + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; /* portal px-4 */ + gap: 16px; +} +@media (min-width: 640px) { + .header-inner { padding: 0 32px; } /* portal sm:px-8 */ +} +.brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} +.brand-icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%); + box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 12px; + letter-spacing: -0.04em; + color: white; +} +.brand-name { + font-size: 14px; /* tailwind text-sm */ + font-weight: 600; + letter-spacing: -0.025em; /* tailwind tracking-tight */ + color: var(--text-primary); +} +.brand-ai { color: var(--primary); } +.header-crumb { + font-size: 13px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Main ────────────────────────────────────────── */ +.main { + max-width: 900px; + margin: 0 auto; + padding: 40px 24px 80px; + display: flex; + flex-direction: column; + gap: 32px; +} +@media (max-width: 640px) { + .header-inner { padding: 0 16px; } + .main { padding: 24px 16px 60px; } +} + +/* ── Section intro ───────────────────────────────── */ +.section-intro { margin-bottom: 24px; } +.section-title { + font-size: 22px; + font-weight: 600; + letter-spacing: -0.02em; + margin-bottom: 8px; +} +.section-desc { + color: var(--text-tertiary); + max-width: 580px; + font-size: 14px; + line-height: 1.6; +} + +/* ── Examples panel ──────────────────────────────── */ +.examples-panel { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--card); + box-shadow: var(--shadow-card); + padding: 16px; + margin-bottom: 16px; +} +.examples-header { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 12px; +} +.examples-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} +.examples-subtitle { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; +} +.examples-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 28px; + margin-bottom: 10px; +} +.examples-list:empty::before { + content: "Zatím žádné vzory — přidejte alespoň jeden níže"; + font-size: 12px; + color: var(--text-quaternary); + font-style: italic; + padding: 6px 0; +} +.example-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent); + color: var(--text-primary); + padding: 4px 4px 4px 10px; + border-radius: 999px; + font-size: 12px; + font-family: ui-monospace, monospace; +} +.example-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-tertiary); + padding: 0; + transition: background 0.1s, color 0.1s; +} +.example-chip-remove:hover { + background: color-mix(in srgb, var(--primary) 18%, transparent); + color: var(--text-primary); +} +.example-chip-remove svg { + width: 12px; + height: 12px; +} +.examples-input-row { + display: flex; + gap: 8px; +} +.example-input { + flex: 1; + font-family: ui-monospace, monospace; + font-size: 13px; + padding: 8px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-primary); + min-width: 0; +} +.example-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} +.example-add-btn { + flex-shrink: 0; +} + +/* ── Drop zone ───────────────────────────────────── */ +.drop-zone { + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--card); + padding: 56px 32px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + box-shadow: var(--shadow-card); +} +.drop-zone:hover, .drop-zone.drag-over { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 4%, var(--card)); +} +.drop-icon { + width: 44px; + height: 44px; + color: var(--text-quaternary); + margin: 0 auto 18px; + display: block; + transition: color 0.15s; +} +.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon { + color: var(--primary); +} +.drop-text { + font-size: 15px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} +.drop-or { + font-size: 13px; + color: var(--text-quaternary); + margin-bottom: 14px; +} +.drop-formats { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 14px; +} + +/* ── Buttons ─────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.15s, box-shadow 0.15s; + line-height: 1; +} +.btn-primary { + background: var(--primary); + color: #fff; + box-shadow: 0 1px 2px rgb(21 90 239 / 0.25); +} +.btn-primary:hover { background: var(--primary-hover); } +.btn-primary:disabled { opacity: 0.5; cursor: default; } +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-strong); +} +.btn-secondary:hover { background: var(--border-strong); } +.btn-icon { width: 15px; height: 15px; flex-shrink: 0; } + +/* ── Processing card ─────────────────────────────── */ +.processing-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 56px 32px; + text-align: center; + box-shadow: var(--shadow-card); +} +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border-strong); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin: 0 auto 22px; +} +@keyframes spin { to { transform: rotate(360deg); } } +.processing-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 20px; + color: var(--text-primary); +} +.steps-list { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; + margin: 0 auto; + text-align: left; +} +.step-item { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text-tertiary); + padding: 8px 12px; + border-radius: var(--radius-md); + background: var(--bg-tertiary); +} +.step-item.active { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); +} +.step-item.done { color: #17b26a; } +.step-item.error { color: #d92d20; background: #fef3f2; } +.step-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +/* ── Results ─────────────────────────────────────── */ +.results-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.results-title { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.015em; + margin-bottom: 4px; +} +.results-meta { + font-size: 13px; + color: var(--text-tertiary); +} +.results-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ── Table ───────────────────────────────────────── */ +.table-wrap { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-card); + background: var(--card); + overflow-x: auto; +} +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + min-width: 500px; +} +thead { + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-default); +} +th { + padding: 10px 14px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + white-space: nowrap; +} +td { + padding: 8px 14px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-primary); + vertical-align: middle; +} +tr:last-child td { border-bottom: none; } +tr:nth-child(even) td { background: var(--bg-secondary); } +td[contenteditable]:focus { + outline: none; + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); + box-shadow: inset 0 0 0 1.5px var(--primary); + border-radius: 3px; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 11px; + font-weight: 500; +} +.badge-rule { background: #d1e0ff; color: #004aeb; } +.badge-llm { background: #d1fae5; color: #065f46; } +@media (prefers-color-scheme: dark) { + .badge-rule { background: #1e3a8a; color: #93c5fd; } + .badge-llm { background: #064e3b; color: #6ee7b7; } +} + +.table-hint { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 8px; +} + +.hidden { display: none !important; } diff --git a/vv-check/Dockerfile b/vv-check/Dockerfile new file mode 100644 index 0000000..5ae66d3 --- /dev/null +++ b/vv-check/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN mkdir -p /tmp/vv-check +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/vv-check/docker-compose.yml b/vv-check/docker-compose.yml new file mode 100644 index 0000000..7b83d62 --- /dev/null +++ b/vv-check/docker-compose.yml @@ -0,0 +1,18 @@ +services: + vv-check: + build: . + container_name: vv-check + restart: unless-stopped + ports: + - "127.0.0.1:3033:8000" + networks: + - localai + volumes: + - vv-check-data:/tmp/vv-check + +volumes: + vv-check-data: + +networks: + localai: + external: true diff --git a/vv-check/main.py b/vv-check/main.py new file mode 100644 index 0000000..e465148 --- /dev/null +++ b/vv-check/main.py @@ -0,0 +1,79 @@ +"""FastAPI: Excel workbook → detect VV sheets → find price inconsistencies → Excel report.""" +import logging +import os +import uuid +from pathlib import Path + +from fastapi import FastAPI, File, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from vv_logic import analyse, write_report + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="VV Price Check") +app.add_middleware(CORSMiddleware, allow_origins=["*"], + allow_methods=["*"], allow_headers=["*"]) + +WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/vv-check")) +WORK_DIR.mkdir(parents=True, exist_ok=True) + +jobs: dict[str, dict] = {} + + +@app.get("/") +async def root(): + return FileResponse("static/index.html") + + +@app.post("/api/check") +async def check(file: UploadFile = File(...)): + suffix = Path(file.filename or "").suffix.lower() + if suffix not in (".xlsx", ".xlsm"): + raise HTTPException(400, "Podporované formáty: .xlsx, .xlsm") + + job_id = str(uuid.uuid4()) + job_dir = WORK_DIR / job_id + job_dir.mkdir() + input_path = job_dir / f"input{suffix}" + input_path.write_bytes(await file.read()) + logger.info("Job %s: %s (%d bytes)", job_id, file.filename, input_path.stat().st_size) + + try: + result = analyse(input_path) + except Exception as exc: + logger.exception("Analysis failed") + raise HTTPException(500, f"Analýza selhala: {exc}") + + jobs[job_id] = { + "filename": file.filename, + "job_dir": str(job_dir), + "result": result, + } + return {"job_id": job_id, "result": result} + + +@app.get("/api/report/{job_id}") +async def report(job_id: str): + if job_id not in jobs: + raise HTTPException(404, "Úloha nenalezena") + job = jobs[job_id] + out_path = Path(job["job_dir"]) / "report.xlsx" + write_report(job["result"], job.get("filename") or "kalkulace.xlsx", out_path) + stem = Path(job["filename"]).stem if job.get("filename") else "kalkulace" + return FileResponse( + str(out_path), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + filename=f"kontrola_cen_{stem}.xlsx", + ) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/vv-check/requirements.txt b/vv-check/requirements.txt new file mode 100644 index 0000000..9c61c3f --- /dev/null +++ b/vv-check/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +openpyxl>=3.1 +python-multipart>=0.0.9 diff --git a/vv-check/static/app.js b/vv-check/static/app.js new file mode 100644 index 0000000..350b4c0 --- /dev/null +++ b/vv-check/static/app.js @@ -0,0 +1,179 @@ +// VV price-check frontend +(() => { + const $ = (id) => document.getElementById(id); + const sections = { + upload: $("s-upload"), + processing: $("s-processing"), + result: $("s-result"), + }; + const show = (n) => { + for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== n); + }; + + let state = { jobId: null, result: null }; + + // Upload + const fileInput = $("file-input"); + const dropZone = $("drop-zone"); + $("browse-btn").addEventListener("click", () => fileInput.click()); + fileInput.addEventListener("change", (e) => e.target.files[0] && upload(e.target.files[0])); + ["dragenter", "dragover"].forEach((ev) => + dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); })); + ["dragleave", "drop"].forEach((ev) => + dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); })); + dropZone.addEventListener("drop", (e) => e.dataTransfer.files[0] && upload(e.dataTransfer.files[0])); + + async function upload(file) { + show("processing"); + try { + const fd = new FormData(); + fd.append("file", file); + const r = await fetch("/api/check", { method: "POST", body: fd }); + if (!r.ok) throw new Error((await r.json()).detail || r.statusText); + const json = await r.json(); + state.jobId = json.job_id; + state.result = json.result; + renderResult(); + show("result"); + } catch (err) { + alert("Chyba: " + err.message); + show("upload"); + } + } + + function fmtPrice(v) { + if (v == null || isNaN(v)) return "—"; + return v.toLocaleString("cs-CZ", { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + " Kč"; + } + + function renderResult() { + const r = state.result; + const vvCount = r.vv_sheet_count; + const pricedCount = r.vv_sheets_with_prices || 0; + const incCount = r.total_inconsistencies; + const totalSheets = r.sheets.length; + $("results-meta").textContent = + `${totalSheets} listů v sešitu, ${vvCount} identifikováno jako VV, ${pricedCount} s vyplněnými cenami.`; + + const sheetsHtml = r.sheets.map((s) => { + let cls = "not-vv"; + let label = s.name; + let icon = "·"; + if (s.is_vv && s.priced_items > 0) { + cls = "vv"; + icon = "✓"; + label = `${s.name} (${s.priced_items} s cenou)`; + } else if (s.is_vv && s.items > 0) { + cls = "vv-noprice"; + icon = "⚠"; + label = `${s.name} (${s.items} bez cen)`; + } else if (s.is_vv) { + cls = "not-vv"; + label = `${s.name} (prázdné)`; + } + return `${icon} ${escapeHtml(label)}`; + }).join(""); + + $("sheets-summary").innerHTML = ` +
+
${vvCount}
+
listů VV
+
+
+
${pricedCount}
+
s cenami
+
+
+
${incCount}
+
nesouladů
+
+
${sheetsHtml}
+ `; + + const wrap = $("incs-wrap"); + + // Special case: no VVs at all + if (vvCount === 0) { + wrap.innerHTML = ` +
+

V sešitu nebyl rozpoznán žádný výkaz výměr.

+

+ Aplikace hledá listy s hlavičkou „Popis / MJ / Výměra / Jedn. cena". + Pokud váš sešit používá jiný formát, dejte vědět přes + Návrh nového nástroje. +

+
`; + return; + } + + // VVs found but none have prices — point user at correct tool + if (pricedCount === 0) { + wrap.innerHTML = ` +
+

Nalezeno ${vvCount} listů VV, ale žádný nemá vyplněné jednotkové ceny.

+

+ „Kontrola cen ve VV" porovnává jednotkové ceny napříč listy — bez vyplněných cen není co porovnávat.
+ Pokud chcete porovnat tento sešit s předchozí verzí (změny ve výměrách, přidané/odebrané položky), + použijte Porovnání VV. +

+
`; + return; + } + + // VVs with prices found but no inconsistencies + if (incCount === 0) { + wrap.innerHTML = ` +
+ + + + +

Všechny položky se stejným názvem mají shodnou jednotkovou cenu ve všech VV listech.

+
`; + return; + } + + wrap.innerHTML = r.inconsistencies.map((inc) => { + const minP = Math.min(...inc.rows.map(r => r.unit_price)); + const maxP = Math.max(...inc.rows.map(r => r.unit_price)); + const rows = inc.rows.map((rw) => { + const cls = rw.unit_price === maxP && minP !== maxP ? "row-max" + : rw.unit_price === minP && minP !== maxP ? "row-min" : ""; + return ` + ${escapeHtml(rw.sheet)} + ${rw.row} + ${escapeHtml(rw.mj)} + ${fmtPrice(rw.unit_price)} + `; + }).join(""); + const spread = maxP > 0 ? ((maxP - minP) / minP * 100) : 0; + return ` +
+
${escapeHtml(inc.description)}
+
+ Vyskytuje se ${inc.occurrences}× ve ${new Set(inc.rows.map(r => r.sheet)).size} listech · + rozdíl ${fmtPrice(minP)} – ${fmtPrice(maxP)} (rozptyl ${spread.toFixed(1)} %) +
+ + + ${rows} +
List VVŘádekMJJed. cena
+
`; + }).join(""); + } + + $("download-btn").addEventListener("click", () => { + if (!state.jobId) return; + window.location.href = `/api/report/${state.jobId}`; + }); + $("restart-btn").addEventListener("click", () => { + state = { jobId: null, result: null }; + fileInput.value = ""; + show("upload"); + }); + + function escapeHtml(s) { + return String(s ?? "").replace(/[&<>"']/g, (c) => + ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); + } +})(); diff --git a/vv-check/static/extra.css b/vv-check/static/extra.css new file mode 100644 index 0000000..96ce8ac --- /dev/null +++ b/vv-check/static/extra.css @@ -0,0 +1,133 @@ +/* VV check specific styles */ + +.processing-sub { + font-size: 13px; + color: var(--text-tertiary); + margin: 8px auto 0; + max-width: 400px; +} + +.sheets-summary { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 16px 18px; + margin: 16px 0 18px; + display: flex; + gap: 18px; + flex-wrap: wrap; + font-size: 13px; + color: var(--text-secondary); +} +.summary-stat { display: flex; flex-direction: column; gap: 2px; min-width: 100px; } +.summary-stat-value { + font-size: 22px; + font-weight: 600; + color: var(--text-primary); + font-variant-numeric: tabular-nums; +} +.summary-stat-value.problem { color: #b91c1c; } +.summary-stat-value.ok { color: #16a34a; } +.summary-stat-label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); +} + +.detected-sheets { + margin-left: auto; + display: flex; + flex-wrap: wrap; + gap: 6px; + align-self: center; +} +.sheet-pill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 9px; + border-radius: 999px; + font-size: 11.5px; + background: var(--bg-tertiary); + color: var(--text-secondary); +} +.sheet-pill.vv { background: rgba(34, 197, 94, 0.14); color: #15803d; font-weight: 500; } +.sheet-pill.vv-noprice { background: rgba(245, 158, 11, 0.15); color: #b45309; font-weight: 500; } +.sheet-pill.not-vv { color: var(--text-quaternary); } +.empty-state a { color: var(--primary); } + +.incs-wrap { + display: flex; + flex-direction: column; + gap: 10px; +} +.inc-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 14px 18px; + border-left: 4px solid #f59e0b; +} +.inc-card-title { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 6px; +} +.inc-card-meta { + font-size: 12px; + color: var(--text-tertiary); + margin-bottom: 8px; +} +.inc-table { width: 100%; border-collapse: collapse; font-size: 13px; } +.inc-table th, .inc-table td { + padding: 6px 10px; + border-bottom: 1px solid var(--border-default); + text-align: left; +} +.inc-table th { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-tertiary); + background: var(--bg-secondary); +} +.inc-table td.num { text-align: right; font-variant-numeric: tabular-nums; } +.inc-table tr.row-max td { color: #b91c1c; font-weight: 500; } +.inc-table tr.row-min td { color: #15803d; } + +.empty-state { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 40px 24px; + text-align: center; + color: var(--text-secondary); +} +.empty-state .empty-icon { + width: 48px; + height: 48px; + color: #16a34a; + margin: 0 auto 14px; +} + +/* Back link */ +.back-link { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 12px 6px 10px; border-radius: 8px; + font-size: 13px; font-weight: 500; color: var(--text-tertiary); + text-decoration: none; border: 0.5px solid var(--border-default); + background: var(--bg-primary); flex-shrink: 0; + transition: color .15s, border-color .15s, background .15s; +} +.back-link:hover { + color: var(--primary); border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); +} +.back-link svg { opacity: 0.8; } +@media (max-width: 640px) { + .back-link span { display: none; } + .back-link { padding: 6px; } +} diff --git a/vv-check/static/index.html b/vv-check/static/index.html new file mode 100644 index 0000000..b701979 --- /dev/null +++ b/vv-check/static/index.html @@ -0,0 +1,104 @@ + + + + + + Kontrola cen ve VV | Colsys AI + + + + + + + + + +
+ +
+ +
+ +
+
+

Kontrola jednotkových cen ve výkazech výměr

+

+ Nahrajte Excel s interní kalkulací (více listů typu VV). Aplikace + najde položky se stejným názvem ve více VV listech a upozorní + na rozdílné jednotkové ceny. +

+
+ +
+ + + +

Přetáhněte sešit XLSX sem

+

nebo

+ +

Podporované formáty: .xlsx, .xlsm

+ +
+
+ + + + + +
+ + + + diff --git a/vv-check/static/styles.css b/vv-check/static/styles.css new file mode 100644 index 0000000..9efda74 --- /dev/null +++ b/vv-check/static/styles.css @@ -0,0 +1,461 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f2f4f7; + --text-primary: #101828; + --text-secondary: #354052; + --text-tertiary: #676f83; + --text-quaternary: #98a2b2; + --border-default: rgb(16 24 40 / 0.08); + --border-strong: #d0d5dc; + --border-subtle: rgb(16 24 40 / 0.04); + --card: #ffffff; + --primary: #155aef; + --primary-hover: #004aeb; + --accent-indigo: #444ce7; + --shadow-card: 0 1px 2px rgb(16 24 40 / 0.05); + --radius-md: 8px; + --radius-lg: 12px; +} + +/* Dark theme — applies when (a) user OS prefers dark and no .light override, + or (b) :root has explicit .dark class (set by portal_theme cookie). */ +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; + } +} +:root.dark { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; +} + +body { + font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 100vh; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Header ─────────────────────────────────────── */ +.header { + position: sticky; + top: 0; + z-index: 30; + border-bottom: 0.5px solid var(--border-default); + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + backdrop-filter: blur(8px); +} +.header-inner { + max-width: 1280px; /* match portal max-w-7xl so brand doesn't shift */ + margin: 0 auto; + height: 56px; /* portal uses h-14 */ + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; /* portal px-4 */ + gap: 16px; +} +@media (min-width: 640px) { + .header-inner { padding: 0 32px; } /* portal sm:px-8 */ +} +.brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} +.brand-icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%); + box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 12px; + letter-spacing: -0.04em; + color: white; +} +.brand-name { + font-size: 14px; /* tailwind text-sm */ + font-weight: 600; + letter-spacing: -0.025em; /* tailwind tracking-tight */ + color: var(--text-primary); +} +.brand-ai { color: var(--primary); } +.header-crumb { + font-size: 13px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Main ────────────────────────────────────────── */ +.main { + max-width: 900px; + margin: 0 auto; + padding: 40px 24px 80px; + display: flex; + flex-direction: column; + gap: 32px; +} +@media (max-width: 640px) { + .header-inner { padding: 0 16px; } + .main { padding: 24px 16px 60px; } +} + +/* ── Section intro ───────────────────────────────── */ +.section-intro { margin-bottom: 24px; } +.section-title { + font-size: 22px; + font-weight: 600; + letter-spacing: -0.02em; + margin-bottom: 8px; +} +.section-desc { + color: var(--text-tertiary); + max-width: 580px; + font-size: 14px; + line-height: 1.6; +} + +/* ── Examples panel ──────────────────────────────── */ +.examples-panel { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--card); + box-shadow: var(--shadow-card); + padding: 16px; + margin-bottom: 16px; +} +.examples-header { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 12px; +} +.examples-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} +.examples-subtitle { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; +} +.examples-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 28px; + margin-bottom: 10px; +} +.examples-list:empty::before { + content: "Zatím žádné vzory — přidejte alespoň jeden níže"; + font-size: 12px; + color: var(--text-quaternary); + font-style: italic; + padding: 6px 0; +} +.example-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent); + color: var(--text-primary); + padding: 4px 4px 4px 10px; + border-radius: 999px; + font-size: 12px; + font-family: ui-monospace, monospace; +} +.example-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-tertiary); + padding: 0; + transition: background 0.1s, color 0.1s; +} +.example-chip-remove:hover { + background: color-mix(in srgb, var(--primary) 18%, transparent); + color: var(--text-primary); +} +.example-chip-remove svg { + width: 12px; + height: 12px; +} +.examples-input-row { + display: flex; + gap: 8px; +} +.example-input { + flex: 1; + font-family: ui-monospace, monospace; + font-size: 13px; + padding: 8px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-primary); + min-width: 0; +} +.example-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} +.example-add-btn { + flex-shrink: 0; +} + +/* ── Drop zone ───────────────────────────────────── */ +.drop-zone { + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--card); + padding: 56px 32px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + box-shadow: var(--shadow-card); +} +.drop-zone:hover, .drop-zone.drag-over { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 4%, var(--card)); +} +.drop-icon { + width: 44px; + height: 44px; + color: var(--text-quaternary); + margin: 0 auto 18px; + display: block; + transition: color 0.15s; +} +.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon { + color: var(--primary); +} +.drop-text { + font-size: 15px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} +.drop-or { + font-size: 13px; + color: var(--text-quaternary); + margin-bottom: 14px; +} +.drop-formats { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 14px; +} + +/* ── Buttons ─────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.15s, box-shadow 0.15s; + line-height: 1; +} +.btn-primary { + background: var(--primary); + color: #fff; + box-shadow: 0 1px 2px rgb(21 90 239 / 0.25); +} +.btn-primary:hover { background: var(--primary-hover); } +.btn-primary:disabled { opacity: 0.5; cursor: default; } +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-strong); +} +.btn-secondary:hover { background: var(--border-strong); } +.btn-icon { width: 15px; height: 15px; flex-shrink: 0; } + +/* ── Processing card ─────────────────────────────── */ +.processing-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 56px 32px; + text-align: center; + box-shadow: var(--shadow-card); +} +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border-strong); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin: 0 auto 22px; +} +@keyframes spin { to { transform: rotate(360deg); } } +.processing-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 20px; + color: var(--text-primary); +} +.steps-list { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; + margin: 0 auto; + text-align: left; +} +.step-item { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text-tertiary); + padding: 8px 12px; + border-radius: var(--radius-md); + background: var(--bg-tertiary); +} +.step-item.active { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); +} +.step-item.done { color: #17b26a; } +.step-item.error { color: #d92d20; background: #fef3f2; } +.step-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +/* ── Results ─────────────────────────────────────── */ +.results-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.results-title { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.015em; + margin-bottom: 4px; +} +.results-meta { + font-size: 13px; + color: var(--text-tertiary); +} +.results-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ── Table ───────────────────────────────────────── */ +.table-wrap { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-card); + background: var(--card); + overflow-x: auto; +} +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + min-width: 500px; +} +thead { + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-default); +} +th { + padding: 10px 14px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + white-space: nowrap; +} +td { + padding: 8px 14px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-primary); + vertical-align: middle; +} +tr:last-child td { border-bottom: none; } +tr:nth-child(even) td { background: var(--bg-secondary); } +td[contenteditable]:focus { + outline: none; + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); + box-shadow: inset 0 0 0 1.5px var(--primary); + border-radius: 3px; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 11px; + font-weight: 500; +} +.badge-rule { background: #d1e0ff; color: #004aeb; } +.badge-llm { background: #d1fae5; color: #065f46; } +@media (prefers-color-scheme: dark) { + .badge-rule { background: #1e3a8a; color: #93c5fd; } + .badge-llm { background: #064e3b; color: #6ee7b7; } +} + +.table-hint { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 8px; +} + +.hidden { display: none !important; } diff --git a/vv-check/vv_logic.py b/vv-check/vv_logic.py new file mode 100644 index 0000000..3144632 --- /dev/null +++ b/vv-check/vv_logic.py @@ -0,0 +1,312 @@ +"""Detect VV sheets in an Excel workbook and find items with inconsistent +unit prices across them. + +A "VV" sheet is identified by either: + - Its name contains "VV" (case-insensitive), OR + - It has a typical VV header row with columns matching Poř./Kód/Popis/MJ/Výměra/cena. + +Items are matched by their description text (normalised: trimmed, multiple +spaces collapsed). The unit-price comparison is exact (rounded to 2 decimals +to absorb floating-point noise). +""" +import logging +import re +from collections import defaultdict +from pathlib import Path +from typing import Iterable + +import openpyxl +from openpyxl.utils import get_column_letter +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.workbook import Workbook + +logger = logging.getLogger(__name__) + +# Heuristic header keywords (Czech). We look for the row with at least three of these. +HEADER_HINTS = { + "popis": ["popis", "název", "název položky", "naziv"], + "mj": ["mj", "j.j.", "jednotka", "měrná jednotka"], + "vymera": ["výměra", "vymera", "množství", "mnozstvi", "počet", "pocet"], + # Unit-price column. Common Czech spellings include "Jedn. cena", + # "J. cena", "Jednotková cena", "cena/jed", "kč/mj", ... + "cena_jed": ["jednotková cena", "jednotkova cena", + "jedn. cena", "jedn.cena", "jedn cena", + "j. cena", "j.cena", "j cena", + "jed. cena", "jed.cena", "jed cena", + "cena/jed", "cena za jednotku", "cena j.", + "cena jed", "cena za mj", "kč/mj"], + "cena_tot": ["cena celkem", "cena", "celkem"], +} + +# Allowed VV-name patterns (case-insensitive substring match). +VV_NAME_PATTERNS = ["vv", "výkaz", "vykaz"] + + +def normalise(text) -> str: + if text is None: + return "" + return re.sub(r"\s+", " ", str(text).strip()).lower() + + +def is_vv_sheet(ws) -> tuple[bool, dict | None]: + """Return (is_vv, header_columns) where header_columns maps role → col index (1-based).""" + name_match = any(p in ws.title.lower() for p in VV_NAME_PATTERNS) + # Scan first 12 rows for a header row + header_row = None + header_cols: dict[str, int] = {} + for row_idx in range(1, min(13, ws.max_row + 1)): + row_values = [(c, normalise(ws.cell(row=row_idx, column=c).value)) + for c in range(1, min(15, ws.max_column + 1))] + matched_roles = {} + for col_idx, val in row_values: + for role, hints in HEADER_HINTS.items(): + if role in matched_roles: + continue + if any(val == h or val.startswith(h) for h in hints): + matched_roles[role] = col_idx + break + if len(matched_roles) >= 3 and "popis" in matched_roles \ + and ("cena_jed" in matched_roles or "cena_tot" in matched_roles): + header_row = row_idx + header_cols = matched_roles + header_cols["_header_row"] = row_idx + break + return ((name_match or header_row is not None), header_cols if header_row else None) + + +def extract_items(ws, header_cols: dict) -> list[dict]: + """Yield item dicts from a VV sheet given its header columns. + + Returns items even when unit_price is missing (None) so the UI can + report "this sheet is a VV but has no prices" instead of silently + dropping everything. + """ + header_row = header_cols.get("_header_row", 1) + popis_col = header_cols.get("popis") + mj_col = header_cols.get("mj") + vymera_col = header_cols.get("vymera") + cena_jed_col = header_cols.get("cena_jed") + if not popis_col: + return [] + + items: list[dict] = [] + for r in range(header_row + 1, ws.max_row + 1): + popis = ws.cell(row=r, column=popis_col).value + if popis is None or not str(popis).strip(): + continue + popis_text = str(popis).strip() + # Skip section rows like "001: Rozvaděče" — empty MJ + colon in popis + mj_val = ws.cell(row=r, column=mj_col).value if mj_col else None + if not mj_val and ":" in popis_text and len(popis_text) < 60: + continue + up: float | None = None + if cena_jed_col: + raw_price = ws.cell(row=r, column=cena_jed_col).value + up = _to_float(raw_price) + if up is not None: + up = round(up, 2) + items.append({ + "row": r, + "description": popis_text, + "description_norm": normalise(popis_text), + "mj": str(mj_val).strip() if mj_val else "", + "quantity": _to_float(ws.cell(row=r, column=vymera_col).value) if vymera_col else None, + "unit_price": up, + }) + return items + + +def _to_float(v): + if v is None or v == "": + return None + try: + return float(v) + except (ValueError, TypeError): + return None + + +def analyse(xlsx_path: Path) -> dict: + """Run the full price-check analysis. Returns a structured report.""" + wb = openpyxl.load_workbook(xlsx_path, data_only=True) + sheets_info = [] + vv_items: dict[str, list[dict]] = {} + + for ws in wb.worksheets: + is_vv, header_cols = is_vv_sheet(ws) + info = { + "name": ws.title, + "is_vv": bool(is_vv), + "items": 0, # total rows recognised as items + "priced_items": 0, # items with a unit price filled in + "has_unit_price_col": False, + } + if is_vv and header_cols: + info["has_unit_price_col"] = bool(header_cols.get("cena_jed")) + items = extract_items(ws, header_cols) + info["items"] = len(items) + info["priced_items"] = sum(1 for it in items if it["unit_price"] is not None) + vv_items[ws.title] = items + sheets_info.append(info) + + # Only items WITH a unit price participate in the price-consistency check + grouped: dict[str, list[tuple[str, dict]]] = defaultdict(list) + for sheet_name, items in vv_items.items(): + for it in items: + if it["unit_price"] is None: + continue + grouped[it["description_norm"]].append((sheet_name, it)) + + # Inconsistencies: same description appearing in 2+ sheets with different price + inconsistencies = [] + for desc_norm, entries in grouped.items(): + if len(entries) < 2: + continue + sheets_present = {s for s, _ in entries} + if len(sheets_present) < 2: + continue # appears multiple times in same sheet — not a cross-sheet issue + prices = {round(it["unit_price"], 2) for _, it in entries} + if len(prices) < 2: + continue + # Use the longest seen description as canonical (more readable) + canonical = max((it["description"] for _, it in entries), key=len) + rows = [] + for sheet_name, it in entries: + rows.append({ + "sheet": sheet_name, + "row": it["row"], + "mj": it["mj"], + "unit_price": it["unit_price"], + }) + inconsistencies.append({ + "description": canonical, + "occurrences": len(entries), + "distinct_prices": sorted(prices), + "rows": rows, + }) + + # Sort by description for stable output + inconsistencies.sort(key=lambda x: x["description"].lower()) + + vv_sheets_with_prices = sum( + 1 for s in sheets_info if s["is_vv"] and s["priced_items"] > 0 + ) + return { + "sheets": sheets_info, + "vv_sheet_count": sum(1 for s in sheets_info if s["is_vv"]), + "vv_sheets_with_prices": vv_sheets_with_prices, + "total_inconsistencies": len(inconsistencies), + "inconsistencies": inconsistencies, + } + + +# ── Excel report writer ───────────────────────────────────────────── + +BLUE = "1F4E78" +WHITE = "FFFFFF" +GRAY = "F2F2F2" +RED_BG = "FCE4E4" +THIN = Side(style="thin", color="BFBFBF") +BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) + + +def write_report(result: dict, source_filename: str, out_path: Path) -> Path: + wb = Workbook() + ws = wb.active + ws.title = "Nesoulady" + + # Title row + ws.cell(row=1, column=1, value="Kontrola jednotkových cen ve výkazech výměr").font = \ + Font(name="Arial", bold=True, size=14, color=BLUE) + ws.merge_cells("A1:F1") + ws.cell(row=2, column=1, value=f"Zdroj: {source_filename}").font = \ + Font(name="Arial", italic=True, size=10, color="595959") + ws.merge_cells("A2:F2") + + # Header + headers = ["Název položky", "List VV", "Řádek", "MJ", "Jednotková cena", "Poznámka"] + for c, h in enumerate(headers, 1): + cell = ws.cell(row=4, column=c, value=h) + cell.font = Font(name="Arial", bold=True, size=11, color=WHITE) + cell.fill = PatternFill("solid", fgColor=BLUE) + cell.alignment = Alignment(horizontal="center", vertical="center") + cell.border = BORDER + + row = 5 + if not result["inconsistencies"]: + ws.cell(row=row, column=1, + value="Žádné nesoulady — všechny položky se stejným názvem mají shodné jednotkové ceny.").font = \ + Font(name="Arial", size=11, color="006100", italic=True) + ws.merge_cells(start_row=row, start_column=1, end_row=row, end_column=6) + else: + for inc in result["inconsistencies"]: + min_price = min(r["unit_price"] for r in inc["rows"]) + max_price = max(r["unit_price"] for r in inc["rows"]) + for r_info in inc["rows"]: + note_parts = [] + if r_info["unit_price"] == min_price: + note_parts.append("nejnižší") + if r_info["unit_price"] == max_price: + note_parts.append("nejvyšší") + note = ", ".join(note_parts) + values = [ + inc["description"], + r_info["sheet"], + r_info["row"], + r_info["mj"], + r_info["unit_price"], + note, + ] + for c, v in enumerate(values, 1): + cell = ws.cell(row=row, column=c, value=v) + cell.font = Font(name="Arial", size=10) + cell.border = BORDER + cell.alignment = Alignment(vertical="top", + wrap_text=(c == 1)) + if c == 5: + cell.number_format = '#,##0.00 "Kč";[Red]-#,##0.00 "Kč";"-"' + cell.alignment = Alignment(horizontal="right", vertical="top") + # Highlight rows with the highest price as a visual cue + if r_info["unit_price"] == max_price and min_price != max_price: + for c in range(1, 7): + ws.cell(row=row, column=c).fill = PatternFill("solid", fgColor=RED_BG) + row += 1 + row += 0 + + # Column widths + widths = {1: 56, 2: 22, 3: 8, 4: 8, 5: 16, 6: 16} + for c, w in widths.items(): + ws.column_dimensions[get_column_letter(c)].width = w + ws.freeze_panes = "A5" + ws.auto_filter.ref = f"A4:F{max(5, row - 1)}" + + # ── Second sheet: per-sheet breakdown ───────────────────── + s2 = wb.create_sheet("Detekované listy") + s2.cell(row=1, column=1, value="Přehled listů v sešitu").font = \ + Font(name="Arial", bold=True, size=14, color=BLUE) + s2.merge_cells("A1:D1") + s2_headers = ["Název listu", "Je VV?", "Počet položek s cenou", "Poznámka"] + for c, h in enumerate(s2_headers, 1): + cell = s2.cell(row=3, column=c, value=h) + cell.font = Font(name="Arial", bold=True, size=11, color=WHITE) + cell.fill = PatternFill("solid", fgColor=BLUE) + cell.border = BORDER + r = 4 + for s in result["sheets"]: + priced = s.get("priced_items", 0) + s2.cell(row=r, column=1, value=s["name"]).border = BORDER + s2.cell(row=r, column=2, value=("Ano" if s["is_vv"] else "Ne")).border = BORDER + s2.cell(row=r, column=3, value=priced).border = BORDER + note = "" + if s["is_vv"] and priced == 0: + note = "list VV bez jednotkových cen — nelze kontrolovat" + elif not s["is_vv"]: + note = "neidentifikován jako VV" + s2.cell(row=r, column=4, value=note).border = BORDER + for c in range(1, 5): + s2.cell(row=r, column=c).font = Font(name="Arial", size=10) + r += 1 + for c, w in {1: 30, 2: 10, 3: 22, 4: 40}.items(): + s2.column_dimensions[get_column_letter(c)].width = w + + wb.save(str(out_path)) + return out_path diff --git a/vv-compare/Dockerfile b/vv-compare/Dockerfile new file mode 100644 index 0000000..51133ef --- /dev/null +++ b/vv-compare/Dockerfile @@ -0,0 +1,8 @@ +FROM python:3.12-slim +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . +RUN mkdir -p /tmp/vv-compare +EXPOSE 8000 +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/vv-compare/docker-compose.yml b/vv-compare/docker-compose.yml new file mode 100644 index 0000000..164e2c2 --- /dev/null +++ b/vv-compare/docker-compose.yml @@ -0,0 +1,18 @@ +services: + vv-compare: + build: . + container_name: vv-compare + restart: unless-stopped + ports: + - "127.0.0.1:3034:8000" + networks: + - localai + volumes: + - vv-compare-data:/tmp/vv-compare + +volumes: + vv-compare-data: + +networks: + localai: + external: true diff --git a/vv-compare/main.py b/vv-compare/main.py new file mode 100644 index 0000000..e4b7c93 --- /dev/null +++ b/vv-compare/main.py @@ -0,0 +1,90 @@ +"""FastAPI: two Excel VVs (original + new) → comparison report Excel.""" +import logging +import os +import uuid +from pathlib import Path + +from fastapi import FastAPI, File, HTTPException, UploadFile +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import FileResponse +from fastapi.staticfiles import StaticFiles + +from vv_compare import compare, write_compare_report + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +app = FastAPI(title="VV Compare") +app.add_middleware(CORSMiddleware, allow_origins=["*"], + allow_methods=["*"], allow_headers=["*"]) + +WORK_DIR = Path(os.getenv("WORK_DIR", "/tmp/vv-compare")) +WORK_DIR.mkdir(parents=True, exist_ok=True) + +jobs: dict[str, dict] = {} + + +@app.get("/") +async def root(): + return FileResponse("static/index.html") + + +@app.post("/api/compare") +async def do_compare( + original: UploadFile = File(...), + new: UploadFile = File(...), +): + for f in (original, new): + suffix = Path(f.filename or "").suffix.lower() + if suffix not in (".xlsx", ".xlsm"): + raise HTTPException(400, "Podporované formáty: .xlsx, .xlsm") + + job_id = str(uuid.uuid4()) + job_dir = WORK_DIR / job_id + job_dir.mkdir() + orig_path = job_dir / f"original{Path(original.filename).suffix}" + new_path = job_dir / f"new{Path(new.filename).suffix}" + orig_path.write_bytes(await original.read()) + new_path.write_bytes(await new.read()) + logger.info("Job %s: original=%s, new=%s", job_id, original.filename, new.filename) + + try: + result = compare(orig_path, new_path) + except Exception as exc: + logger.exception("Compare failed") + raise HTTPException(500, f"Porovnání selhalo: {exc}") + + jobs[job_id] = { + "original_filename": original.filename, + "new_filename": new.filename, + "job_dir": str(job_dir), + "result": result, + } + return {"job_id": job_id, "result": result} + + +@app.get("/api/report/{job_id}") +async def report(job_id: str): + if job_id not in jobs: + raise HTTPException(404, "Úloha nenalezena") + job = jobs[job_id] + out_path = Path(job["job_dir"]) / "report.xlsx" + write_compare_report( + job["result"], + job.get("original_filename") or "puvodni.xlsx", + job.get("new_filename") or "novy.xlsx", + out_path, + ) + return FileResponse( + str(out_path), + media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + filename="Porovnani_VV_puvodni_vs_novy.xlsx", + ) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +app.mount("/static", StaticFiles(directory="static"), name="static") diff --git a/vv-compare/requirements.txt b/vv-compare/requirements.txt new file mode 100644 index 0000000..9c61c3f --- /dev/null +++ b/vv-compare/requirements.txt @@ -0,0 +1,4 @@ +fastapi>=0.115 +uvicorn[standard]>=0.30 +openpyxl>=3.1 +python-multipart>=0.0.9 diff --git a/vv-compare/static/app.js b/vv-compare/static/app.js new file mode 100644 index 0000000..24376fb --- /dev/null +++ b/vv-compare/static/app.js @@ -0,0 +1,130 @@ +// VV compare frontend +(() => { + const $ = (id) => document.getElementById(id); + const sections = { + upload: $("s-upload"), + processing: $("s-processing"), + result: $("s-result"), + }; + const show = (n) => { + for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== n); + }; + + let state = { jobId: null, result: null, origFile: null, newFile: null }; + + function attachDrop(zoneId, inputId, textId, which) { + const zone = $(zoneId); + const input = $(inputId); + const text = $(textId); + zone.addEventListener("click", () => input.click()); + input.addEventListener("change", (e) => { + if (e.target.files[0]) setFile(which, e.target.files[0], zone, text); + }); + ["dragenter", "dragover"].forEach((ev) => + zone.addEventListener(ev, (e) => { e.preventDefault(); zone.classList.add("drag-over"); })); + ["dragleave", "drop"].forEach((ev) => + zone.addEventListener(ev, (e) => { e.preventDefault(); zone.classList.remove("drag-over"); })); + zone.addEventListener("drop", (e) => { + if (e.dataTransfer.files[0]) setFile(which, e.dataTransfer.files[0], zone, text); + }); + } + + function setFile(which, file, zone, textEl) { + if (which === "orig") state.origFile = file; + else state.newFile = file; + zone.classList.add("has-file"); + const size = file.size > 1024 * 1024 + ? `${(file.size / 1024 / 1024).toFixed(1)} MB` + : `${(file.size / 1024).toFixed(0)} kB`; + textEl.innerHTML = `${escapeHtml(file.name)}
${size}`; + updateRunBtn(); + } + + attachDrop("drop-original", "orig-input", "orig-text", "orig"); + attachDrop("drop-new", "new-input", "new-text", "new"); + + function updateRunBtn() { + const btn = $("compare-btn"); + const hint = $("compare-hint"); + if (!state.origFile && !state.newFile) { + btn.disabled = true; hint.textContent = "Nahrajte oba soubory."; + } else if (!state.origFile) { + btn.disabled = true; hint.textContent = "Chybí původní VV."; + } else if (!state.newFile) { + btn.disabled = true; hint.textContent = "Chybí nový VV."; + } else { + btn.disabled = false; hint.textContent = "Připraveno k porovnání."; + } + } + + $("compare-btn").addEventListener("click", async () => { + if (!state.origFile || !state.newFile) return; + show("processing"); + try { + const fd = new FormData(); + fd.append("original", state.origFile); + fd.append("new", state.newFile); + const r = await fetch("/api/compare", { method: "POST", body: fd }); + if (!r.ok) throw new Error((await r.json()).detail || r.statusText); + const json = await r.json(); + state.jobId = json.job_id; + state.result = json.result; + renderResult(); + show("result"); + } catch (err) { + alert("Chyba: " + err.message); + show("upload"); + } + }); + + function renderResult() { + const r = state.result; + const c = r.changes.length, a = r.added.length, d = r.removed.length; + $("cnt-changed").textContent = c; + $("cnt-added").textContent = a; + $("cnt-removed").textContent = d; + $("results-meta").textContent = + `Porovnání ${r.per_sheet.length} listů — ${c + a + d} celkových rozdílů.`; + + const psHtml = r.per_sheet.map((ps) => ` + + ${escapeHtml(ps.sheet)} + ${escapeHtml(ps.hala || "")} + ${ps.orig_count} + ${ps.new_count} + ${ps.changes} + ${ps.added} + ${ps.removed} + `).join(""); + $("per-sheet").innerHTML = r.per_sheet.length ? ` + + + + + + + + + ${psHtml} +
ListHalaPol. původníPol. novýZměněnéPřidanéOdebrané
` : `

Nepodařilo se najít listy VV v žádném ze souborů.

`; + } + + $("download-btn").addEventListener("click", () => { + if (state.jobId) window.location.href = `/api/report/${state.jobId}`; + }); + $("restart-btn").addEventListener("click", () => { + state = { jobId: null, result: null, origFile: null, newFile: null }; + $("orig-input").value = ""; $("new-input").value = ""; + $("drop-original").classList.remove("has-file"); + $("drop-new").classList.remove("has-file"); + $("orig-text").textContent = "Přetáhněte soubor sem nebo klikněte"; + $("new-text").textContent = "Přetáhněte soubor sem nebo klikněte"; + updateRunBtn(); + show("upload"); + }); + + function escapeHtml(s) { + return String(s ?? "").replace(/[&<>"']/g, (c) => + ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); + } +})(); diff --git a/vv-compare/static/extra.css b/vv-compare/static/extra.css new file mode 100644 index 0000000..5a60895 --- /dev/null +++ b/vv-compare/static/extra.css @@ -0,0 +1,149 @@ +/* VV compare specific */ + +.processing-sub { + font-size: 13px; + color: var(--text-tertiary); + margin: 8px auto 0; + max-width: 400px; +} + +.dual-drop { + display: flex; + align-items: stretch; + gap: 14px; + margin-bottom: 18px; +} +.drop-half { flex: 1; display: flex; flex-direction: column; gap: 6px; } +.drop-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-tertiary); + padding-left: 4px; +} +.drop-zone-mini { + flex: 1; + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--bg-secondary); + padding: 32px 18px; + text-align: center; + cursor: pointer; + transition: border-color .15s, background .15s; + min-height: 140px; + display: flex; + align-items: center; + justify-content: center; +} +.drop-zone-mini:hover { border-color: var(--primary); } +.drop-zone-mini.drag-over { border-color: var(--primary); background: color-mix(in srgb, var(--primary) 5%, var(--bg-secondary)); } +.drop-zone-mini.has-file { + border-style: solid; + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 4%, var(--bg-secondary)); +} +.drop-arrow { + align-self: center; + font-size: 22px; + color: var(--text-quaternary); + font-weight: 200; + padding: 0 4px; +} +@media (max-width: 640px) { + .dual-drop { flex-direction: column; } + .drop-arrow { transform: rotate(90deg); padding: 4px 0; } +} + +.run-row { + display: flex; align-items: center; gap: 14px; + padding-top: 16px; border-top: 1px solid var(--border-default); + flex-wrap: wrap; +} +.btn-lg { padding: 12px 26px; font-size: 14px; font-weight: 600; } +.run-hint { font-size: 13px; color: var(--text-tertiary); } + +.recap-cards { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 14px; + margin: 16px 0 22px; +} +.recap-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 18px 22px; + border-left: 4px solid #94a3b8; +} +.recap-card.recap-changed { border-left-color: #f59e0b; } +.recap-card.recap-added { border-left-color: #22c55e; } +.recap-card.recap-removed { border-left-color: #ef4444; } +.recap-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-tertiary); + margin-bottom: 6px; +} +.recap-value { + font-size: 32px; + font-weight: 600; + color: var(--text-primary); + font-variant-numeric: tabular-nums; + line-height: 1; +} + +.per-sheet { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 16px 18px; + overflow-x: auto; +} +.per-sheet table { width: 100%; border-collapse: collapse; font-size: 13px; } +.per-sheet th, .per-sheet td { + padding: 8px 10px; + border-bottom: 1px solid var(--border-default); + text-align: left; +} +.per-sheet th { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--text-tertiary); + background: var(--bg-secondary); +} +.per-sheet td.num { text-align: right; font-variant-numeric: tabular-nums; } +.per-sheet .pill { + display: inline-block; + padding: 1px 7px; + border-radius: 999px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} +.per-sheet .pill.changed { background: rgba(245,158,11,0.15); color: #b45309; } +.per-sheet .pill.added { background: rgba(34,197,94,0.15); color: #15803d; } +.per-sheet .pill.removed { background: rgba(239,68,68,0.15); color: #b91c1c; } +.per-sheet .pill.zero { background: var(--bg-tertiary); color: var(--text-quaternary); } + +/* Back link */ +.back-link { + display: inline-flex; align-items: center; gap: 6px; + padding: 6px 12px 6px 10px; border-radius: 8px; + font-size: 13px; font-weight: 500; color: var(--text-tertiary); + text-decoration: none; border: 0.5px solid var(--border-default); + background: var(--bg-primary); flex-shrink: 0; + transition: color .15s, border-color .15s, background .15s; +} +.back-link:hover { + color: var(--primary); border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); +} +.back-link svg { opacity: 0.8; } +@media (max-width: 640px) { + .back-link span { display: none; } + .back-link { padding: 6px; } +} diff --git a/vv-compare/static/index.html b/vv-compare/static/index.html new file mode 100644 index 0000000..f4d0b06 --- /dev/null +++ b/vv-compare/static/index.html @@ -0,0 +1,131 @@ + + + + + + Porovnání VV | Colsys AI + + + + + + + + + +
+ +
+ +
+ +
+
+

Porovnání výkazu výměr (původní vs nový)

+

+ Nahrajte dva soubory MaR VV — původní a nový. Aplikace najde + změny ve výměrách, MJ, přidané a odebrané položky a vytvoří + souhrnný Excel report. +

+
+ +
+
+
PŮVODNÍ VV
+
+

Přetáhněte soubor sem nebo klikněte

+ +
+
+
+
+
NOVÝ VV
+
+

Přetáhněte soubor sem nebo klikněte

+ +
+
+
+ +
+ + Nahrajte oba soubory. +
+
+ + + + + +
+ + + + diff --git a/vv-compare/static/styles.css b/vv-compare/static/styles.css new file mode 100644 index 0000000..9efda74 --- /dev/null +++ b/vv-compare/static/styles.css @@ -0,0 +1,461 @@ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg-primary: #ffffff; + --bg-secondary: #f9fafb; + --bg-tertiary: #f2f4f7; + --text-primary: #101828; + --text-secondary: #354052; + --text-tertiary: #676f83; + --text-quaternary: #98a2b2; + --border-default: rgb(16 24 40 / 0.08); + --border-strong: #d0d5dc; + --border-subtle: rgb(16 24 40 / 0.04); + --card: #ffffff; + --primary: #155aef; + --primary-hover: #004aeb; + --accent-indigo: #444ce7; + --shadow-card: 0 1px 2px rgb(16 24 40 / 0.05); + --radius-md: 8px; + --radius-lg: 12px; +} + +/* Dark theme — applies when (a) user OS prefers dark and no .light override, + or (b) :root has explicit .dark class (set by portal_theme cookie). */ +@media (prefers-color-scheme: dark) { + :root:not(.light) { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; + } +} +:root.dark { + --bg-primary: #14181f; + --bg-secondary: #1a1f29; + --bg-tertiary: #232936; + --text-primary: #f5f7fa; + --text-secondary: #c8ccd5; + --text-tertiary: #98a2b2; + --text-quaternary: #676f83; + --border-default: rgb(255 255 255 / 0.08); + --border-strong: #354052; + --border-subtle: rgb(255 255 255 / 0.04); + --card: #1a1f29; +} + +body { + font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg-secondary); + color: var(--text-primary); + min-height: 100vh; + font-size: 14px; + line-height: 1.5; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* ── Header ─────────────────────────────────────── */ +.header { + position: sticky; + top: 0; + z-index: 30; + border-bottom: 0.5px solid var(--border-default); + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + backdrop-filter: blur(8px); +} +.header-inner { + max-width: 1280px; /* match portal max-w-7xl so brand doesn't shift */ + margin: 0 auto; + height: 56px; /* portal uses h-14 */ + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px; /* portal px-4 */ + gap: 16px; +} +@media (min-width: 640px) { + .header-inner { padding: 0 32px; } /* portal sm:px-8 */ +} +.brand { + display: flex; + align-items: center; + gap: 10px; + text-decoration: none; + flex-shrink: 0; +} +.brand-icon { + width: 28px; + height: 28px; + border-radius: 8px; + background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%); + box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18); + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 12px; + letter-spacing: -0.04em; + color: white; +} +.brand-name { + font-size: 14px; /* tailwind text-sm */ + font-weight: 600; + letter-spacing: -0.025em; /* tailwind tracking-tight */ + color: var(--text-primary); +} +.brand-ai { color: var(--primary); } +.header-crumb { + font-size: 13px; + color: var(--text-tertiary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Main ────────────────────────────────────────── */ +.main { + max-width: 900px; + margin: 0 auto; + padding: 40px 24px 80px; + display: flex; + flex-direction: column; + gap: 32px; +} +@media (max-width: 640px) { + .header-inner { padding: 0 16px; } + .main { padding: 24px 16px 60px; } +} + +/* ── Section intro ───────────────────────────────── */ +.section-intro { margin-bottom: 24px; } +.section-title { + font-size: 22px; + font-weight: 600; + letter-spacing: -0.02em; + margin-bottom: 8px; +} +.section-desc { + color: var(--text-tertiary); + max-width: 580px; + font-size: 14px; + line-height: 1.6; +} + +/* ── Examples panel ──────────────────────────────── */ +.examples-panel { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + background: var(--card); + box-shadow: var(--shadow-card); + padding: 16px; + margin-bottom: 16px; +} +.examples-header { + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 12px; +} +.examples-title { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} +.examples-subtitle { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1.5; +} +.examples-list { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 28px; + margin-bottom: 10px; +} +.examples-list:empty::before { + content: "Zatím žádné vzory — přidejte alespoň jeden níže"; + font-size: 12px; + color: var(--text-quaternary); + font-style: italic; + padding: 6px 0; +} +.example-chip { + display: inline-flex; + align-items: center; + gap: 6px; + background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary)); + border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent); + color: var(--text-primary); + padding: 4px 4px 4px 10px; + border-radius: 999px; + font-size: 12px; + font-family: ui-monospace, monospace; +} +.example-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 50%; + background: transparent; + border: none; + cursor: pointer; + color: var(--text-tertiary); + padding: 0; + transition: background 0.1s, color 0.1s; +} +.example-chip-remove:hover { + background: color-mix(in srgb, var(--primary) 18%, transparent); + color: var(--text-primary); +} +.example-chip-remove svg { + width: 12px; + height: 12px; +} +.examples-input-row { + display: flex; + gap: 8px; +} +.example-input { + flex: 1; + font-family: ui-monospace, monospace; + font-size: 13px; + padding: 8px 12px; + border: 1px solid var(--border-strong); + border-radius: var(--radius-md); + background: var(--bg-primary); + color: var(--text-primary); + min-width: 0; +} +.example-input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent); +} +.example-add-btn { + flex-shrink: 0; +} + +/* ── Drop zone ───────────────────────────────────── */ +.drop-zone { + border: 1.5px dashed var(--border-strong); + border-radius: var(--radius-lg); + background: var(--card); + padding: 56px 32px; + text-align: center; + cursor: pointer; + transition: border-color 0.15s, background 0.15s; + box-shadow: var(--shadow-card); +} +.drop-zone:hover, .drop-zone.drag-over { + border-color: var(--primary); + background: color-mix(in srgb, var(--primary) 4%, var(--card)); +} +.drop-icon { + width: 44px; + height: 44px; + color: var(--text-quaternary); + margin: 0 auto 18px; + display: block; + transition: color 0.15s; +} +.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon { + color: var(--primary); +} +.drop-text { + font-size: 15px; + font-weight: 500; + color: var(--text-secondary); + margin-bottom: 6px; +} +.drop-or { + font-size: 13px; + color: var(--text-quaternary); + margin-bottom: 14px; +} +.drop-formats { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 14px; +} + +/* ── Buttons ─────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 16px; + border-radius: var(--radius-md); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: none; + transition: background 0.15s, box-shadow 0.15s; + line-height: 1; +} +.btn-primary { + background: var(--primary); + color: #fff; + box-shadow: 0 1px 2px rgb(21 90 239 / 0.25); +} +.btn-primary:hover { background: var(--primary-hover); } +.btn-primary:disabled { opacity: 0.5; cursor: default; } +.btn-secondary { + background: var(--bg-tertiary); + color: var(--text-secondary); + border: 1px solid var(--border-strong); +} +.btn-secondary:hover { background: var(--border-strong); } +.btn-icon { width: 15px; height: 15px; flex-shrink: 0; } + +/* ── Processing card ─────────────────────────────── */ +.processing-card { + background: var(--card); + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + padding: 56px 32px; + text-align: center; + box-shadow: var(--shadow-card); +} +.spinner { + width: 36px; + height: 36px; + border: 3px solid var(--border-strong); + border-top-color: var(--primary); + border-radius: 50%; + animation: spin 0.75s linear infinite; + margin: 0 auto 22px; +} +@keyframes spin { to { transform: rotate(360deg); } } +.processing-title { + font-size: 16px; + font-weight: 600; + margin-bottom: 20px; + color: var(--text-primary); +} +.steps-list { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 320px; + margin: 0 auto; + text-align: left; +} +.step-item { + display: flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--text-tertiary); + padding: 8px 12px; + border-radius: var(--radius-md); + background: var(--bg-tertiary); +} +.step-item.active { + color: var(--primary); + background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary)); +} +.step-item.done { color: #17b26a; } +.step-item.error { color: #d92d20; background: #fef3f2; } +.step-dot { + width: 8px; height: 8px; + border-radius: 50%; + background: currentColor; + flex-shrink: 0; +} + +/* ── Results ─────────────────────────────────────── */ +.results-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + flex-wrap: wrap; +} +.results-title { + font-size: 18px; + font-weight: 600; + letter-spacing: -0.015em; + margin-bottom: 4px; +} +.results-meta { + font-size: 13px; + color: var(--text-tertiary); +} +.results-actions { display: flex; gap: 8px; flex-wrap: wrap; } + +/* ── Table ───────────────────────────────────────── */ +.table-wrap { + border: 1px solid var(--border-default); + border-radius: var(--radius-lg); + overflow: hidden; + box-shadow: var(--shadow-card); + background: var(--card); + overflow-x: auto; +} +table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + min-width: 500px; +} +thead { + background: var(--bg-tertiary); + border-bottom: 1px solid var(--border-default); +} +th { + padding: 10px 14px; + text-align: left; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); + white-space: nowrap; +} +td { + padding: 8px 14px; + border-bottom: 1px solid var(--border-subtle); + color: var(--text-primary); + vertical-align: middle; +} +tr:last-child td { border-bottom: none; } +tr:nth-child(even) td { background: var(--bg-secondary); } +td[contenteditable]:focus { + outline: none; + background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary)); + box-shadow: inset 0 0 0 1.5px var(--primary); + border-radius: 3px; +} + +.badge { + display: inline-block; + padding: 2px 8px; + border-radius: 99px; + font-size: 11px; + font-weight: 500; +} +.badge-rule { background: #d1e0ff; color: #004aeb; } +.badge-llm { background: #d1fae5; color: #065f46; } +@media (prefers-color-scheme: dark) { + .badge-rule { background: #1e3a8a; color: #93c5fd; } + .badge-llm { background: #064e3b; color: #6ee7b7; } +} + +.table-hint { + font-size: 12px; + color: var(--text-quaternary); + margin-top: 8px; +} + +.hidden { display: none !important; } diff --git a/vv-compare/vv_compare.py b/vv-compare/vv_compare.py new file mode 100644 index 0000000..db131ca --- /dev/null +++ b/vv-compare/vv_compare.py @@ -0,0 +1,507 @@ +"""Compare original vs new VV (Výkaz Výměr) Excel files. + +Produces a 4-sheet report: + - Souhrn — overview table with per-sheet counts + grand totals + - Změny — items present in both but with different quantity / MJ + - Přidané — items in new but not in original + - Odebrané — items in original but not in new + +Following Jirka's spec exactly: section rows are skipped, change = qty/MJ +diff only (NOT price), heavy use of colour-coding. +""" +import logging +import re +from collections import defaultdict +from pathlib import Path + +import openpyxl +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter +from openpyxl.workbook import Workbook + +logger = logging.getLogger(__name__) + +HEADER_HINTS = { + "por": ["poř.", "por.", "pořadí", "č.", "č"], + "kod": ["kód", "kod"], + "popis": ["popis", "název", "název položky"], + "mj": ["mj", "j.j.", "jednotka"], + "vymera": ["výměra", "vymera", "množství", "mnozstvi"], + "cena_jed": ["jednotková cena", "jednotkova cena", "j. cena", "j.cena", + "jed. cena", "cena/jed", "cena j.", "cena za jednotku"], + "cena_tot": ["cena celkem", "celkem", "cena"], +} + +# Colors per spec +BLUE = "1F4E78" +WHITE = "FFFFFF" +GRAY = "F2F2F2" +YELLOW = "FFF2CC" # changes +GREEN_BG = "D9EAD3" # added +RED_BG = "F4CCCC" # removed +GREEN_DIFF = "E4F0DC" # positive qty diff +RED_DIFF = "FCE4E4" # negative qty diff +DARK_GREEN = "006100" +DARK_RED = "C00000" +GRAY_TEXT = "595959" +BORDER_GRAY = "BFBFBF" + +THIN = Side(style="thin", color=BORDER_GRAY) +BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN) + + +def normalise(text) -> str: + if text is None: + return "" + return re.sub(r"\s+", " ", str(text).strip()) + + +def _to_float(v): + if v is None or v == "": + return None + try: + return float(v) + except (ValueError, TypeError): + return None + + +def find_header(ws) -> dict | None: + """Return header column mapping or None if no VV-like header found.""" + for row_idx in range(1, min(13, ws.max_row + 1)): + matched = {} + for col_idx in range(1, min(15, ws.max_column + 1)): + val = normalise(ws.cell(row=row_idx, column=col_idx).value).lower() + for role, hints in HEADER_HINTS.items(): + if role in matched: + continue + if any(val == h or val.startswith(h) for h in hints): + matched[role] = col_idx + break + if "popis" in matched and "vymera" in matched and "mj" in matched: + matched["_header_row"] = row_idx + return matched + return None + + +def extract_items(ws, header: dict) -> tuple[list[dict], str | None]: + """Return (items, hala_name).""" + header_row = header["_header_row"] + # Hall/object name: row 4 col C per spec, but be flexible + hala = None + for r in range(1, header_row): + for c in range(1, min(8, ws.max_column + 1)): + v = ws.cell(row=r, column=c).value + if v and isinstance(v, str) and 5 < len(v) < 200 \ + and "vykaz" not in v.lower() and "výkaz" not in v.lower(): + hala = v.strip() + break + if hala: + break + + items = [] + current_section = None + popis_col = header["popis"] + mj_col = header["mj"] + vymera_col = header["vymera"] + cena_jed_col = header.get("cena_jed") + cena_tot_col = header.get("cena_tot") + kod_col = header.get("kod") + + for r in range(header_row + 1, ws.max_row + 1): + popis = ws.cell(row=r, column=popis_col).value + if popis is None or not str(popis).strip(): + continue + popis_text = str(popis).strip() + mj_val = ws.cell(row=r, column=mj_col).value + # Detect section row: empty MJ + description contains ":" + if (not mj_val or not str(mj_val).strip()) and ":" in popis_text \ + and len(popis_text) < 100: + current_section = popis_text + continue + vymera = _to_float(ws.cell(row=r, column=vymera_col).value) + # Skip rows without quantity (probably subtotals) + if vymera is None: + continue + items.append({ + "row": r, + "section": current_section, + "kod": ws.cell(row=r, column=kod_col).value if kod_col else None, + "description": popis_text, + "description_norm": normalise(popis_text), + "mj": str(mj_val).strip() if mj_val else "", + "vymera": vymera, + "cena_jed": _to_float(ws.cell(row=r, column=cena_jed_col).value) if cena_jed_col else None, + "cena_tot": _to_float(ws.cell(row=r, column=cena_tot_col).value) if cena_tot_col else None, + }) + return items, hala + + +def analyse_workbook(path: Path) -> dict: + """Return {sheet_name: {items, hala, header_found}}.""" + wb = openpyxl.load_workbook(path, data_only=True) + out = {} + for ws in wb.worksheets: + header = find_header(ws) + if not header: + continue + items, hala = extract_items(ws, header) + if not items: + continue + out[ws.title] = {"items": items, "hala": hala, "header": header} + wb.close() + return out + + +def compare(orig_path: Path, new_path: Path) -> dict: + orig_sheets = analyse_workbook(orig_path) + new_sheets = analyse_workbook(new_path) + + # Match sheets by exact name first; fall back to position-based match for unmatched. + matched_pairs: list[tuple[str | None, str | None]] = [] + orig_used: set[str] = set() + new_used: set[str] = set() + for name in orig_sheets: + if name in new_sheets: + matched_pairs.append((name, name)) + orig_used.add(name) + new_used.add(name) + remaining_orig = [s for s in orig_sheets if s not in orig_used] + remaining_new = [s for s in new_sheets if s not in new_used] + # Position match by order of declaration + for o, n in zip(remaining_orig, remaining_new): + matched_pairs.append((o, n)) + # Orphans (only orig) + for o in remaining_orig[len(remaining_new):]: + matched_pairs.append((o, None)) + for n in remaining_new[len(remaining_orig):]: + matched_pairs.append((None, n)) + + per_sheet = [] + all_changes = [] + all_added = [] + all_removed = [] + + for orig_name, new_name in matched_pairs: + orig_items = orig_sheets.get(orig_name, {}).get("items", []) if orig_name else [] + new_items = new_sheets.get(new_name, {}).get("items", []) if new_name else [] + hala = (new_sheets.get(new_name, {}).get("hala") if new_name else None) \ + or (orig_sheets.get(orig_name, {}).get("hala") if orig_name else None) \ + or "" + sheet_label = new_name or orig_name or "" + + # Pair items by (section, description_norm) + orig_index = defaultdict(list) + for it in orig_items: + orig_index[(it["section"] or "", it["description_norm"])].append(it) + new_index = defaultdict(list) + for it in new_items: + new_index[(it["section"] or "", it["description_norm"])].append(it) + + changes = [] + added = [] + removed = [] + + # Items present in both + for key, new_list in new_index.items(): + orig_list = orig_index.get(key, []) + if not orig_list: + for it in new_list: + added.append({ + "sheet": sheet_label, "hala": hala, + "section": key[0], "description": it["description"], + "mj": it["mj"], "vymera": it["vymera"], + "cena_jed": it["cena_jed"], "cena_tot": it["cena_tot"], + }) + continue + # Compare in pairs (one-to-one by index) + for idx, new_it in enumerate(new_list): + if idx >= len(orig_list): + # Duplicate added entry + added.append({ + "sheet": sheet_label, "hala": hala, + "section": key[0], "description": new_it["description"], + "mj": new_it["mj"], "vymera": new_it["vymera"], + "cena_jed": new_it["cena_jed"], "cena_tot": new_it["cena_tot"], + }) + continue + orig_it = orig_list[idx] + qty_diff = (new_it["vymera"] or 0) - (orig_it["vymera"] or 0) + mj_diff = orig_it["mj"] != new_it["mj"] + if abs(qty_diff) > 1e-9 or mj_diff: + changes.append({ + "sheet": sheet_label, "hala": hala, + "section": key[0], "description": new_it["description"], + "mj_orig": orig_it["mj"], "mj_new": new_it["mj"], + "vymera_orig": orig_it["vymera"], "vymera_new": new_it["vymera"], + "vymera_diff": qty_diff, + "cena_jed_orig": orig_it["cena_jed"], + "cena_orig": orig_it["cena_tot"], + "cena_new": new_it["cena_tot"], + }) + # Items only in original + for key, orig_list in orig_index.items(): + if key in new_index: + # Handle leftover originals (orig had more duplicates than new) + used = min(len(new_index[key]), len(orig_list)) + for it in orig_list[used:]: + removed.append({ + "sheet": sheet_label, "hala": hala, + "section": key[0], "description": it["description"], + "mj": it["mj"], "vymera": it["vymera"], + "cena_jed": it["cena_jed"], "cena_tot": it["cena_tot"], + }) + continue + for it in orig_list: + removed.append({ + "sheet": sheet_label, "hala": hala, + "section": key[0], "description": it["description"], + "mj": it["mj"], "vymera": it["vymera"], + "cena_jed": it["cena_jed"], "cena_tot": it["cena_tot"], + }) + + per_sheet.append({ + "sheet": sheet_label, + "hala": hala, + "orig_count": len(orig_items), + "new_count": len(new_items), + "changes": len(changes), + "added": len(added), + "removed": len(removed), + }) + all_changes.extend(changes) + all_added.extend(added) + all_removed.extend(removed) + + return { + "per_sheet": per_sheet, + "changes": all_changes, + "added": all_added, + "removed": all_removed, + } + + +# ── Excel report writer (4 sheets per spec) ─────────────────── + +NUM_QTY = '#,##0.##;[Red]-#,##0.##;"-"' +NUM_CZK = '#,##0.00 "Kč";[Red]-#,##0.00 "Kč";"-"' + + +def _title(ws, text: str, subtitle: str = ""): + cell = ws.cell(row=1, column=1, value=text) + cell.font = Font(name="Arial", bold=True, size=14, color=BLUE) + ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=12) + if subtitle: + sub = ws.cell(row=2, column=1, value=subtitle) + sub.font = Font(name="Arial", italic=True, size=10, color=GRAY_TEXT) + ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=12) + + +def _hdr_cell(cell): + cell.font = Font(name="Arial", bold=True, size=11, color=WHITE) + cell.fill = PatternFill("solid", fgColor=BLUE) + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + cell.border = BORDER + + +def _body_cell(cell, num_format: str | None = None, fill: str | None = None, + bold: bool = False, color: str | None = None, + horizontal: str | None = None): + cell.font = Font(name="Arial", size=10, bold=bold, + color=color or "000000") + cell.border = BORDER + if fill: + cell.fill = PatternFill("solid", fgColor=fill) + if num_format: + cell.number_format = num_format + cell.alignment = Alignment(horizontal=horizontal or "left", + vertical="top", + wrap_text=True) + + +def write_compare_report(result: dict, orig_filename: str, new_filename: str, + out_path: Path) -> Path: + wb = Workbook() + + # ── 1. Souhrn ───────────────────────────────────────── + ws = wb.active + ws.title = "Souhrn" + _title(ws, "Porovnání výkazu výměr", + f"Původní: {orig_filename} · Nový: {new_filename}") + + headers = ["List", "Hala / objekt", "Počet pol. (původní)", "Počet pol. (nový)", + "Změněné položky", "Přidané položky", "Odebrané položky"] + for c, h in enumerate(headers, 1): + _hdr_cell(ws.cell(row=4, column=c, value=h)) + r = 5 + for ps in result["per_sheet"]: + ws.cell(row=r, column=1, value=ps["sheet"]) + ws.cell(row=r, column=2, value=ps["hala"]) + ws.cell(row=r, column=3, value=ps["orig_count"]) + ws.cell(row=r, column=4, value=ps["new_count"]) + ws.cell(row=r, column=5, value=ps["changes"]) + ws.cell(row=r, column=6, value=ps["added"]) + ws.cell(row=r, column=7, value=ps["removed"]) + for c in range(1, 8): + _body_cell(ws.cell(row=r, column=c)) + # Colored highlights when > 0 + if ps["changes"]: + _body_cell(ws.cell(row=r, column=5), fill=YELLOW, bold=True, + horizontal="right") + if ps["added"]: + _body_cell(ws.cell(row=r, column=6), fill=GREEN_BG, bold=True, + color=DARK_GREEN, horizontal="right") + if ps["removed"]: + _body_cell(ws.cell(row=r, column=7), fill=RED_BG, bold=True, + color=DARK_RED, horizontal="right") + r += 1 + # CELKEM row + if result["per_sheet"]: + first_data_row = 5 + last_data_row = r - 1 + ws.cell(row=r, column=1, value="CELKEM") + for c in range(3, 8): + col_letter = get_column_letter(c) + ws.cell(row=r, column=c, + value=f"=SUM({col_letter}{first_data_row}:{col_letter}{last_data_row})") + for c in range(1, 8): + _body_cell(ws.cell(row=r, column=c), + fill=GRAY, bold=True, + horizontal=("right" if c >= 3 else "left")) + r += 1 + + # Recap box + r += 1 + ws.cell(row=r, column=1, value="Rekapitulace změn (celkem)").font = \ + Font(name="Arial", bold=True, size=12, color=BLUE) + r += 1 + for label, cnt, fill, color in [ + ("Změněné", len(result["changes"]), YELLOW, None), + ("Přidané", len(result["added"]), GREEN_BG, DARK_GREEN), + ("Odebrané", len(result["removed"]), RED_BG, DARK_RED), + ]: + _body_cell(ws.cell(row=r, column=1, value=label), bold=True) + _body_cell(ws.cell(row=r, column=2, value=cnt), + fill=fill, bold=True, color=color, horizontal="right") + r += 1 + + r += 1 + note = ws.cell(row=r, column=1, + value=("Za „změnu\" je považován pouze rozdíl ve výměře nebo MJ. " + "Cenové rozdíly se ignorují, protože nový VV obvykle ceny " + "neobsahuje.")) + note.font = Font(name="Arial", italic=True, size=9, color=GRAY_TEXT) + note.alignment = Alignment(wrap_text=True) + ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=7) + + for c, w in {1: 22, 2: 30, 3: 18, 4: 18, 5: 18, 6: 18, 7: 18}.items(): + ws.column_dimensions[get_column_letter(c)].width = w + ws.row_dimensions[4].height = 32 + + # ── 2. Změny ───────────────────────────────────────── + ws = wb.create_sheet("Změny") + _title(ws, "Změněné položky (rozdíl ve výměře nebo MJ)") + cols = ["List", "Hala", "Sekce", "Popis položky", + "MJ orig.", "MJ nová", "Výměra orig.", "Výměra nová", "Rozdíl výměra", + "Jed. cena orig.", "Cena orig.", "Cena nová"] + for c, h in enumerate(cols, 1): + _hdr_cell(ws.cell(row=4, column=c, value=h)) + r = 5 + for ch in result["changes"]: + ws.cell(row=r, column=1, value=ch["sheet"]) + ws.cell(row=r, column=2, value=ch["hala"]) + ws.cell(row=r, column=3, value=ch["section"]) + ws.cell(row=r, column=4, value=ch["description"]) + ws.cell(row=r, column=5, value=ch["mj_orig"]) + ws.cell(row=r, column=6, value=ch["mj_new"]) + ws.cell(row=r, column=7, value=ch["vymera_orig"]) + ws.cell(row=r, column=8, value=ch["vymera_new"]) + ws.cell(row=r, column=9, value=ch["vymera_diff"]) + ws.cell(row=r, column=10, value=ch["cena_jed_orig"]) + ws.cell(row=r, column=11, value=ch["cena_orig"]) + ws.cell(row=r, column=12, value=ch["cena_new"]) + for c in range(1, 13): + _body_cell(ws.cell(row=r, column=c)) + # Number formats + for c in (7, 8, 9): + ws.cell(row=r, column=c).number_format = NUM_QTY + ws.cell(row=r, column=c).alignment = Alignment(horizontal="right", vertical="top") + for c in (10, 11, 12): + ws.cell(row=r, column=c).number_format = NUM_CZK + ws.cell(row=r, column=c).alignment = Alignment(horizontal="right", vertical="top") + # Highlight diff cell + diff_fill = GREEN_DIFF if ch["vymera_diff"] > 0 else RED_DIFF + _body_cell(ws.cell(row=r, column=9), fill=diff_fill, bold=True, + num_format=NUM_QTY, horizontal="right") + r += 1 + + widths = {1: 14, 2: 22, 3: 22, 4: 50, 5: 10, 6: 10, 7: 14, 8: 14, 9: 14, 10: 14, 11: 14, 12: 14} + for c, w in widths.items(): + ws.column_dimensions[get_column_letter(c)].width = w + ws.freeze_panes = "A5" + ws.auto_filter.ref = f"A4:L{max(5, r - 1)}" + ws.row_dimensions[4].height = 32 + + # ── 3. Přidané ───────────────────────────────────────── + ws = wb.create_sheet("Přidané") + _title(ws, "Přidané položky (jsou v novém VV, nebyly v původním)") + add_cols = ["List", "Hala", "Sekce", "Popis položky", "MJ", "Výměra", + "Jed. cena", "Cena"] + for c, h in enumerate(add_cols, 1): + _hdr_cell(ws.cell(row=4, column=c, value=h)) + r = 5 + for it in result["added"]: + vals = [it["sheet"], it["hala"], it["section"], it["description"], + it["mj"], it["vymera"], it["cena_jed"], it["cena_tot"]] + for c, v in enumerate(vals, 1): + _body_cell(ws.cell(row=r, column=c, value=v), + fill=GREEN_BG, color=DARK_GREEN) + ws.cell(row=r, column=6).number_format = NUM_QTY + ws.cell(row=r, column=6).alignment = Alignment(horizontal="right", vertical="top") + ws.cell(row=r, column=7).number_format = NUM_CZK + ws.cell(row=r, column=7).alignment = Alignment(horizontal="right", vertical="top") + ws.cell(row=r, column=8).number_format = NUM_CZK + ws.cell(row=r, column=8).alignment = Alignment(horizontal="right", vertical="top") + r += 1 + for c, w in {1: 14, 2: 22, 3: 22, 4: 50, 5: 10, 6: 14, 7: 14, 8: 14}.items(): + ws.column_dimensions[get_column_letter(c)].width = w + ws.freeze_panes = "A5" + ws.auto_filter.ref = f"A4:H{max(5, r - 1)}" + ws.row_dimensions[4].height = 32 + + # ── 4. Odebrané ───────────────────────────────────────── + ws = wb.create_sheet("Odebrané") + _title(ws, "Odebrané položky (byly v původním VV, v novém nejsou)") + for c, h in enumerate(add_cols, 1): + _hdr_cell(ws.cell(row=4, column=c, value=h)) + r = 5 + first_data_row = r + for it in result["removed"]: + vals = [it["sheet"], it["hala"], it["section"], it["description"], + it["mj"], it["vymera"], it["cena_jed"], it["cena_tot"]] + for c, v in enumerate(vals, 1): + _body_cell(ws.cell(row=r, column=c, value=v), + fill=RED_BG, color=DARK_RED) + ws.cell(row=r, column=6).number_format = NUM_QTY + ws.cell(row=r, column=6).alignment = Alignment(horizontal="right", vertical="top") + ws.cell(row=r, column=7).number_format = NUM_CZK + ws.cell(row=r, column=7).alignment = Alignment(horizontal="right", vertical="top") + ws.cell(row=r, column=8).number_format = NUM_CZK + ws.cell(row=r, column=8).alignment = Alignment(horizontal="right", vertical="top") + r += 1 + last_data_row = r - 1 + if last_data_row >= first_data_row: + # Sum cena_tot column + ws.cell(row=r, column=1, value="Součet") + ws.cell(row=r, column=8, + value=f"=SUM(H{first_data_row}:H{last_data_row})") + for c in range(1, 9): + _body_cell(ws.cell(row=r, column=c), fill=GRAY, bold=True, + horizontal=("right" if c == 8 else "left")) + ws.cell(row=r, column=8).number_format = NUM_CZK + for c, w in {1: 14, 2: 22, 3: 22, 4: 50, 5: 10, 6: 14, 7: 14, 8: 14}.items(): + ws.column_dimensions[get_column_letter(c)].width = w + ws.freeze_panes = "A5" + ws.row_dimensions[4].height = 32 + + wb.save(str(out_path)) + return out_path