diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ab4d79f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.gitea +.next +node_modules +data +coverage +.env +.env.local +npm-debug.log* +*.tsbuildinfo +tasks diff --git a/.gitea/workflows/master.yml b/.gitea/workflows/master.yml new file mode 100644 index 0000000..ada6d06 --- /dev/null +++ b/.gitea/workflows/master.yml @@ -0,0 +1,144 @@ +name: Master Production Deploy + +on: + push: + branches: + - master + +concurrency: + group: deploy-production + cancel-in-progress: false + +env: + REGISTRY: ${{ vars.CONTAINER_REGISTRY }} + WEB_IMAGE_REPO: ${{ vars.CONTAINER_REGISTRY }}/${{ github.repository_owner }}/${{ github.event.repository.name }}-web + RELEASE_ID: ${{ github.sha }}-${{ github.run_number }} + NPM_CONFIG_AUDIT: "false" + NPM_CONFIG_FUND: "false" + NPM_CONFIG_UPDATE_NOTIFIER: "false" + +jobs: + production: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup npm + run: npm install -g npm@11.6.4 + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Quality gate + run: npm run ci:quality + + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Build and push web image + id: build-web + uses: docker/build-push-action@v6 + with: + context: . + file: deploy/docker/Dockerfile.web + push: true + tags: ${{ env.WEB_IMAGE_REPO }}:${{ github.sha }} + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ env.RELEASE_ID }} + + - name: Production pre-deploy checks + env: + ENVIRONMENT: production + STACK_NAME: ${{ vars.PRODUCTION_STACK_NAME }} + RELEASE_ID: ${{ env.RELEASE_ID }} + COMMIT_SHA: ${{ github.sha }} + WEB_IMAGE: ${{ env.WEB_IMAGE_REPO }}@${{ steps.build-web.outputs.digest }} + PUBLIC_HOST: ${{ vars.PRODUCTION_PUBLIC_HOST }} + TRAEFIK_NETWORK: ${{ vars.PRODUCTION_TRAEFIK_NETWORK }} + SWARM_MANAGER_HOST: ${{ secrets.PRODUCTION_SWARM_MANAGER_HOST }} + SWARM_SSH_USER: ${{ secrets.PRODUCTION_SWARM_SSH_USER }} + run: node deploy/scripts/predeploy-check.mjs + + - name: Deploy production + env: + ENVIRONMENT: production + STACK_NAME: ${{ vars.PRODUCTION_STACK_NAME }} + RELEASE_ID: ${{ env.RELEASE_ID }} + COMMIT_SHA: ${{ github.sha }} + WEB_IMAGE: ${{ env.WEB_IMAGE_REPO }}@${{ steps.build-web.outputs.digest }} + PUBLIC_HOST: ${{ vars.PRODUCTION_PUBLIC_HOST }} + TRAEFIK_NETWORK: ${{ vars.PRODUCTION_TRAEFIK_NETWORK }} + SWARM_MANAGER_HOST: ${{ secrets.PRODUCTION_SWARM_MANAGER_HOST }} + SWARM_SSH_USER: ${{ secrets.PRODUCTION_SWARM_SSH_USER }} + SWARM_SSH_PORT: ${{ secrets.PRODUCTION_SWARM_SSH_PORT }} + REGISTRY: ${{ env.REGISTRY }} + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + WEB_REPLICAS: ${{ vars.PRODUCTION_WEB_REPLICAS }} + WEB_PLACEMENT_CONSTRAINT: ${{ vars.PRODUCTION_WEB_PLACEMENT_CONSTRAINT }} + TRAEFIK_ENTRYPOINTS: ${{ vars.TRAEFIK_ENTRYPOINTS }} + TRAEFIK_TLS: ${{ vars.TRAEFIK_TLS }} + REGION: ${{ vars.REGION }} + TEAM_ID: ${{ vars.TEAM_ID }} + DEPLOY_MIN_FREE_MB: ${{ vars.DEPLOY_MIN_FREE_MB }} + DEPLOY_PRUNE_BEFORE_PULL: ${{ vars.DEPLOY_PRUNE_BEFORE_PULL }} + DEPLOY_PRUNE_UNTIL: ${{ vars.DEPLOY_PRUNE_UNTIL }} + DEPLOY_CLEANUP_FAILED_IMAGES: ${{ vars.DEPLOY_CLEANUP_FAILED_IMAGES }} + VIDEOREG_DB_MEMORY_TTL_MS: ${{ vars.VIDEOREG_DB_MEMORY_TTL_MS }} + VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS: ${{ vars.VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS }} + run: deploy/scripts/deploy-over-ssh.sh + + - name: Production smoke checks + id: smoke + continue-on-error: true + env: + BASE_URL: https://${{ vars.PRODUCTION_PUBLIC_HOST }} + EXPECTED_RELEASE_ID: ${{ env.RELEASE_ID }} + SMOKE_RESULT_PATH: smoke-result.json + run: node deploy/scripts/smoke-check.mjs + + - name: Roll back production after failed smoke checks + if: steps.smoke.outcome == 'failure' + env: + ENVIRONMENT: production + STACK_NAME: ${{ vars.PRODUCTION_STACK_NAME }} + SWARM_MANAGER_HOST: ${{ secrets.PRODUCTION_SWARM_MANAGER_HOST }} + SWARM_SSH_USER: ${{ secrets.PRODUCTION_SWARM_SSH_USER }} + SWARM_SSH_PORT: ${{ secrets.PRODUCTION_SWARM_SSH_PORT }} + run: deploy/scripts/rollback-over-ssh.sh + + - name: Fail after production smoke rollback + if: steps.smoke.outcome == 'failure' + run: exit 1 + + - name: Write release manifest + env: + COMMIT_SHA: ${{ github.sha }} + RELEASE_ID: ${{ env.RELEASE_ID }} + WEB_IMAGE_DIGEST: ${{ env.WEB_IMAGE_REPO }}@${{ steps.build-web.outputs.digest }} + SMOKE_RESULT_PATH: smoke-result.json + WORKFLOW_RUN_ID: ${{ github.run_id }} + run: node deploy/scripts/write-release-manifest.mjs > release-manifest.json + + - name: Upload release manifest + uses: actions/upload-artifact@v4 + with: + name: release-manifest-${{ env.RELEASE_ID }} + path: release-manifest.json diff --git a/.gitea/workflows/pr.yml b/.gitea/workflows/pr.yml new file mode 100644 index 0000000..3e9ece7 --- /dev/null +++ b/.gitea/workflows/pr.yml @@ -0,0 +1,36 @@ +name: PR Quality Gate + +on: + pull_request: + branches: + - master + +concurrency: + group: pr-${{ github.ref }} + cancel-in-progress: true + +env: + NPM_CONFIG_AUDIT: "false" + NPM_CONFIG_FUND: "false" + NPM_CONFIG_UPDATE_NOTIFIER: "false" + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + + - name: Setup npm + run: npm install -g npm@11.6.4 + + - name: Install dependencies + run: npm ci --prefer-offline --no-audit --no-fund + + - name: Quality gate + run: npm run ci:quality diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3102e6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.next/ +node_modules/ +data/ +coverage/ +.env +.env.local +npm-debug.log* +*.tsbuildinfo diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..9f274d6 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +audit=false +fund=false +update-notifier=false diff --git a/README.md b/README.md index e69de29..89aa842 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,37 @@ +## videoreg.ru + +Small Next.js video registry using TypeScript, App Router, FSD-oriented folders, and a primitive file-backed JSON database. + +### Local development + +```bash +npm install +npm run dev +``` + +The app runs on [http://localhost:3000](http://localhost:3000). + +### Data storage + +By default records are stored in `data/videoreg.json`. Override the path with: + +```bash +VIDEOREG_DB_FILE=/absolute/path/videoreg.json npm run dev +``` + +Reads are intentionally cached aggressively: + +- process memory cache for the primitive JSON database; +- Next.js cache tag for dashboard/API reads; +- long HTTP cache headers on the read API. + +Writes call `revalidateTag` and update the process cache. + +### Deployment + +Gitea Actions runs: + +- `.gitea/workflows/pr.yml`: quality gate for pull requests into `master`; +- `.gitea/workflows/master.yml`: quality gate, image build, push, and direct Swarm deploy from `master`. + +The Swarm stack defaults to one replica on a manager node because the primitive database is a mounted JSON file. Use a real database before raising replicas above one or spreading the service across nodes. diff --git a/deploy/docker/Dockerfile.web b/deploy/docker/Dockerfile.web new file mode 100644 index 0000000..afddc0a --- /dev/null +++ b/deploy/docker/Dockerfile.web @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 + +FROM node:22-alpine AS deps +WORKDIR /app + +COPY .npmrc package.json package-lock.json ./ +RUN npm install -g npm@11.6.4 \ + && npm ci --prefer-offline --no-audit --no-fund + +FROM deps AS build +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 +COPY next-env.d.ts next.config.ts tsconfig.json vitest.config.ts ./ +COPY src src +COPY public public +RUN npm run ci:quality + +FROM node:22-alpine AS runtime +ENV NODE_ENV=production \ + APP_ENV=production \ + NEXT_TELEMETRY_DISABLED=1 \ + VIDEOREG_DB_FILE=/data/videoreg.json \ + PORT=3000 +WORKDIR /app + +RUN addgroup -S nodeapp && adduser -S nodeapp -G nodeapp && mkdir -p /data && chown nodeapp:nodeapp /data + +COPY --from=build /app/.next/standalone ./ +COPY --from=build /app/.next/static ./.next/static +COPY --from=build /app/public ./public + +USER nodeapp +EXPOSE 3000 +HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=3 \ + CMD wget -qO- http://127.0.0.1:3000/health/ready >/dev/null || exit 1 + +CMD ["node", "server.js"] diff --git a/deploy/remote/deploy-stack.sh b/deploy/remote/deploy-stack.sh new file mode 100755 index 0000000..7ec9ddb --- /dev/null +++ b/deploy/remote/deploy-stack.sh @@ -0,0 +1,174 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +log() { + printf '{"event":"%s","environment":"%s","stack":"%s","releaseId":"%s","commitSha":"%s"}\n' \ + "$1" "${ENVIRONMENT:-unknown}" "${STACK_NAME:-unknown}" "${RELEASE_ID:-unknown}" "${COMMIT_SHA:-unknown}" +} + +require_var() { + local name="$1" + if [ -z "${!name:-}" ]; then + printf 'Required environment variable %s is missing\n' "$name" >&2 + exit 2 + fi +} + +require_var ENVIRONMENT +require_var STACK_NAME +require_var RELEASE_ID +require_var COMMIT_SHA +require_var WEB_IMAGE +require_var PUBLIC_HOST +require_var TRAEFIK_NETWORK + +REMOTE_DIR="${REMOTE_DEPLOY_DIR:-/tmp/videoreg-deploy}" +LOCK_FILE="${RELEASE_LOCK_FILE:-/tmp/${STACK_NAME}-${ENVIRONMENT}.lock}" + +exec 9>"$LOCK_FILE" +if ! flock -n 9; then + echo "Another deployment is already running for ${STACK_NAME}/${ENVIRONMENT}" >&2 + exit 75 +fi + +cleanup_release_images() { + if [ "${DEPLOY_CLEANUP_FAILED_IMAGES:-true}" != "true" ]; then + return 0 + fi + + echo "Cleaning up unused release image after failed deploy." >&2 + docker image rm "$WEB_IMAGE" >/dev/null 2>&1 || true + + if [ -n "${COMMIT_SHA:-}" ]; then + docker image prune -af --filter "label=org.opencontainers.image.revision=${COMMIT_SHA}" >&2 || true + fi +} + +cleanup_on_exit() { + local status=$? + trap - EXIT + + if [ "$status" -ne 0 ]; then + cleanup_release_images + fi + + exit "$status" +} + +trap cleanup_on_exit EXIT + +docker_disk_paths() { + local docker_root + docker_root="$(docker info --format '{{.DockerRootDir}}' 2>/dev/null || true)" + + if [ -n "$docker_root" ] && [ -d "$docker_root" ]; then + printf '%s\n' "$docker_root" + fi + + if [ -d /var/lib/containerd ]; then + printf '%s\n' /var/lib/containerd + fi +} + +prune_docker_before_pull() { + if [ "${DEPLOY_PRUNE_BEFORE_PULL:-false}" != "true" ]; then + return 0 + fi + + local prune_until="${DEPLOY_PRUNE_UNTIL:-168h}" + echo "Pruning unused Docker images older than ${prune_until} before pulling release image." >&2 + docker image prune -af --filter "until=${prune_until}" >&2 || true + docker builder prune -af --filter "until=${prune_until}" >&2 || true +} + +check_docker_disk_space() { + local min_mb="${DEPLOY_MIN_FREE_MB:-2048}" + if [[ ! "$min_mb" =~ ^[0-9]+$ ]]; then + echo "DEPLOY_MIN_FREE_MB must be an integer MiB value, got: ${min_mb}" >&2 + exit 2 + fi + + local min_kb=$((min_mb * 1024)) + local failed=0 + local path available_kb available_mb + + while IFS= read -r path; do + available_kb="$(df -Pk "$path" | awk 'NR == 2 { print $4 }')" + if [ -z "$available_kb" ]; then + continue + fi + + if [ "$available_kb" -lt "$min_kb" ]; then + available_mb=$((available_kb / 1024)) + echo "Docker storage path ${path} has ${available_mb} MiB free; need at least ${min_mb} MiB before pulling release image." >&2 + failed=1 + fi + done < <(docker_disk_paths | awk '!seen[$0]++') + + if [ "$failed" -ne 0 ]; then + docker system df >&2 || true + exit 1 + fi +} + +wait_service() { + local service="$1" + local timeout="${DEPLOY_WAIT_TIMEOUT_SECONDS:-300}" + local started_at + started_at="$(date +%s)" + + while true; do + local update_state + update_state="$(docker service inspect --format '{{if .UpdateStatus}}{{.UpdateStatus.State}}{{end}}' "$service" 2>/dev/null || true)" + if [ "$update_state" = "rollback_started" ] || [ "$update_state" = "rollback_completed" ] || [ "$update_state" = "paused" ]; then + docker service ps --no-trunc "$service" >&2 || true + exit 1 + fi + + local desired running failed + desired="$(docker service inspect --format '{{if .Spec.Mode.Replicated}}{{.Spec.Mode.Replicated.Replicas}}{{else}}1{{end}}' "$service")" + running="$(docker service ps "$service" --filter desired-state=running --format '{{.CurrentState}}' | grep -c '^Running' || true)" + failed="$(docker service ps "$service" --no-trunc --format '{{.CurrentState}} {{.Error}}' | grep -Ec 'Rejected|Failed|No such image|unhealthy' || true)" + + if [ "$failed" -gt 0 ]; then + docker service ps --no-trunc "$service" >&2 || true + exit 1 + fi + + if [ "$running" -ge "$desired" ]; then + log "service_converged" + return 0 + fi + + if [ "$(( $(date +%s) - started_at ))" -ge "$timeout" ]; then + docker service ps --no-trunc "$service" >&2 || true + echo "Timed out waiting for ${service} convergence" >&2 + exit 1 + fi + + sleep 5 + done +} + +log deploy_start + +if [ -n "${REGISTRY:-}" ] && [ -n "${REGISTRY_USERNAME:-}" ] && [ -n "${REGISTRY_PASSWORD:-}" ]; then + printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY" --username "$REGISTRY_USERNAME" --password-stdin +fi + +prune_docker_before_pull +check_docker_disk_space + +docker image pull "$WEB_IMAGE" + +export ENVIRONMENT STACK_NAME RELEASE_ID COMMIT_SHA WEB_IMAGE PUBLIC_HOST TRAEFIK_NETWORK +export REGION="${REGION:-}" TEAM_ID="${TEAM_ID:-}" WEB_REPLICAS="${WEB_REPLICAS:-1}" +export WEB_PLACEMENT_CONSTRAINT="${WEB_PLACEMENT_CONSTRAINT:-node.role == manager}" +export TRAEFIK_ENTRYPOINTS="${TRAEFIK_ENTRYPOINTS:-websecure}" TRAEFIK_TLS="${TRAEFIK_TLS:-true}" +export VIDEOREG_DB_MEMORY_TTL_MS="${VIDEOREG_DB_MEMORY_TTL_MS:-3600000}" +export VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS="${VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS:-3600}" + +docker stack deploy --with-registry-auth --prune -c "$REMOTE_DIR/docker-stack.yml" "$STACK_NAME" +wait_service "${STACK_NAME}_web" + +log deploy_converged diff --git a/deploy/remote/rollback-stack.sh b/deploy/remote/rollback-stack.sh new file mode 100755 index 0000000..ee4082c --- /dev/null +++ b/deploy/remote/rollback-stack.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +require_var() { + local name="$1" + if [ -z "${!name:-}" ]; then + printf 'Required environment variable %s is missing\n' "$name" >&2 + exit 2 + fi +} + +require_var ENVIRONMENT +require_var STACK_NAME + +LOCK_FILE="${RELEASE_LOCK_FILE:-/tmp/${STACK_NAME}-${ENVIRONMENT}.lock}" +exec 9>"$LOCK_FILE" +flock -n 9 || { + echo "Another deployment operation is already running for ${STACK_NAME}/${ENVIRONMENT}" >&2 + exit 75 +} + +printf '{"event":"rollback_start","environment":"%s","stack":"%s"}\n' "$ENVIRONMENT" "$STACK_NAME" +docker service rollback "${STACK_NAME}_web" || true +printf '{"event":"rollback_requested","environment":"%s","stack":"%s"}\n' "$ENVIRONMENT" "$STACK_NAME" diff --git a/deploy/scripts/deploy-over-ssh.sh b/deploy/scripts/deploy-over-ssh.sh new file mode 100755 index 0000000..37eaa0c --- /dev/null +++ b/deploy/scripts/deploy-over-ssh.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +require_var() { + local name="$1" + if [ -z "${!name:-}" ]; then + printf 'Required environment variable %s is missing\n' "$name" >&2 + exit 2 + fi +} + +quote() { + printf '%q' "$1" +} + +require_var SWARM_MANAGER_HOST +require_var SWARM_SSH_USER +require_var ENVIRONMENT +require_var STACK_NAME +require_var RELEASE_ID +require_var COMMIT_SHA +require_var WEB_IMAGE +require_var PUBLIC_HOST +require_var TRAEFIK_NETWORK + +SSH_PORT="${SWARM_SSH_PORT:-22}" +SSH_TARGET="${SWARM_SSH_USER}@${SWARM_MANAGER_HOST}" +REMOTE_DEPLOY_DIR="${REMOTE_DEPLOY_DIR:-/tmp/videoreg-deploy}" + +ssh -p "$SSH_PORT" "$SSH_TARGET" "mkdir -p $(quote "$REMOTE_DEPLOY_DIR")" +scp -P "$SSH_PORT" deploy/swarm/docker-stack.yml "$SSH_TARGET:$REMOTE_DEPLOY_DIR/docker-stack.yml" +scp -P "$SSH_PORT" deploy/remote/deploy-stack.sh "$SSH_TARGET:$REMOTE_DEPLOY_DIR/deploy-stack.sh" + +remote_vars=( + ENVIRONMENT STACK_NAME RELEASE_ID COMMIT_SHA WEB_IMAGE PUBLIC_HOST TRAEFIK_NETWORK + REGION TEAM_ID WEB_REPLICAS WEB_PLACEMENT_CONSTRAINT TRAEFIK_ENTRYPOINTS TRAEFIK_TLS REGISTRY REGISTRY_USERNAME REGISTRY_PASSWORD + RELEASE_LOCK_FILE REMOTE_DEPLOY_DIR DEPLOY_MIN_FREE_MB DEPLOY_PRUNE_BEFORE_PULL DEPLOY_PRUNE_UNTIL + DEPLOY_CLEANUP_FAILED_IMAGES VIDEOREG_DB_MEMORY_TTL_MS VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS +) + +remote_command="" +for name in "${remote_vars[@]}"; do + if [ -n "${!name:-}" ]; then + remote_command+="$name=$(quote "${!name}") " + fi +done +remote_command+="bash $(quote "$REMOTE_DEPLOY_DIR/deploy-stack.sh")" + +ssh -p "$SSH_PORT" "$SSH_TARGET" "$remote_command" diff --git a/deploy/scripts/predeploy-check.mjs b/deploy/scripts/predeploy-check.mjs new file mode 100644 index 0000000..d112a42 --- /dev/null +++ b/deploy/scripts/predeploy-check.mjs @@ -0,0 +1,32 @@ +const required = [ + 'ENVIRONMENT', + 'STACK_NAME', + 'RELEASE_ID', + 'COMMIT_SHA', + 'WEB_IMAGE', + 'PUBLIC_HOST', + 'TRAEFIK_NETWORK', + 'SWARM_MANAGER_HOST', + 'SWARM_SSH_USER', +]; + +const missing = required.filter((name) => !process.env[name]); +if (missing.length > 0) { + console.error(`Missing required deploy variables: ${missing.join(', ')}`); + process.exit(1); +} + +if (!/@sha256:[a-f0-9]{64}$/i.test(process.env.WEB_IMAGE ?? '')) { + console.error(`WEB_IMAGE must be pinned by immutable digest, got: ${process.env.WEB_IMAGE}`); + process.exit(1); +} + +console.log( + JSON.stringify({ + event: 'predeploy_check_passed', + environment: process.env.ENVIRONMENT, + stack: process.env.STACK_NAME, + releaseId: process.env.RELEASE_ID, + commitSha: process.env.COMMIT_SHA, + }), +); diff --git a/deploy/scripts/rollback-over-ssh.sh b/deploy/scripts/rollback-over-ssh.sh new file mode 100755 index 0000000..40e9872 --- /dev/null +++ b/deploy/scripts/rollback-over-ssh.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +require_var() { + local name="$1" + if [ -z "${!name:-}" ]; then + printf 'Required environment variable %s is missing\n' "$name" >&2 + exit 2 + fi +} + +quote() { + printf '%q' "$1" +} + +require_var SWARM_MANAGER_HOST +require_var SWARM_SSH_USER +require_var ENVIRONMENT +require_var STACK_NAME + +SSH_PORT="${SWARM_SSH_PORT:-22}" +SSH_TARGET="${SWARM_SSH_USER}@${SWARM_MANAGER_HOST}" +REMOTE_DEPLOY_DIR="${REMOTE_DEPLOY_DIR:-/tmp/videoreg-deploy}" + +ssh -p "$SSH_PORT" "$SSH_TARGET" "mkdir -p $(quote "$REMOTE_DEPLOY_DIR")" +scp -P "$SSH_PORT" deploy/remote/rollback-stack.sh "$SSH_TARGET:$REMOTE_DEPLOY_DIR/rollback-stack.sh" + +remote_command="ENVIRONMENT=$(quote "$ENVIRONMENT") STACK_NAME=$(quote "$STACK_NAME") " +if [ -n "${RELEASE_LOCK_FILE:-}" ]; then + remote_command+="RELEASE_LOCK_FILE=$(quote "$RELEASE_LOCK_FILE") " +fi +remote_command+="bash $(quote "$REMOTE_DEPLOY_DIR/rollback-stack.sh")" + +ssh -p "$SSH_PORT" "$SSH_TARGET" "$remote_command" diff --git a/deploy/scripts/smoke-check.mjs b/deploy/scripts/smoke-check.mjs new file mode 100644 index 0000000..0e65525 --- /dev/null +++ b/deploy/scripts/smoke-check.mjs @@ -0,0 +1,138 @@ +import { writeFileSync } from 'node:fs'; + +const baseUrl = process.env.BASE_URL?.replace(/\/$/, ''); +const expectedReleaseId = process.env.EXPECTED_RELEASE_ID; +const smokeWrite = process.env.SMOKE_WRITE === 'true'; +const outputPath = process.env.SMOKE_RESULT_PATH; +const timeoutMs = Number(process.env.SMOKE_TIMEOUT_MS ?? 5000); +const retryAttempts = Number(process.env.SMOKE_RETRY_ATTEMPTS ?? 12); +const retryDelayMs = Number(process.env.SMOKE_RETRY_DELAY_MS ?? 5000); +const retryableStatuses = new Set([404, 408, 425, 429, 500, 502, 503, 504]); + +if (!baseUrl) { + console.error('BASE_URL is required for smoke checks'); + process.exit(2); +} + +function sleep(ms) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +async function requestOnce(path, options = {}) { + const response = await fetch(`${baseUrl}${path}`, { + ...options, + headers: { + 'content-type': 'application/json', + ...options.headers, + }, + signal: AbortSignal.timeout(timeoutMs), + }); + const text = await response.text(); + let body; + try { + body = text ? JSON.parse(text) : undefined; + } catch { + body = text; + } + + if (!response.ok) { + const error = new Error(`${path} returned ${response.status}: ${text}`); + error.status = response.status; + throw error; + } + + return body; +} + +async function request(path, options = {}) { + let lastError; + + for (let attempt = 1; attempt <= retryAttempts; attempt += 1) { + try { + return await requestOnce(path, options); + } catch (error) { + lastError = error; + const status = error?.status; + const retryableStatus = typeof status === 'number' && retryableStatuses.has(status); + const retryableNetworkError = error?.name === 'TimeoutError' || error?.cause?.code; + + if (attempt >= retryAttempts || (!retryableStatus && !retryableNetworkError)) { + throw error; + } + + await sleep(retryDelayMs); + } + } + + throw lastError; +} + +function assertRelease(body) { + if (!expectedReleaseId) return; + const actual = body?.metadata?.releaseId; + if (actual !== expectedReleaseId) { + throw new Error(`service reports release ${actual}, expected ${expectedReleaseId}`); + } +} + +async function run() { + const startedAt = Date.now(); + const checks = []; + + const live = await request('/health/live'); + assertRelease(live); + checks.push({ name: 'web_live', status: 'ok' }); + + const ready = await request('/health/ready'); + assertRelease(ready); + checks.push({ name: 'web_ready', status: 'ok' }); + + const registry = await request('/api/videos'); + if (!registry.stats || !Array.isArray(registry.videos)) { + throw new Error('Video registry API returned an invalid shape'); + } + checks.push({ name: 'registry_read', status: 'ok' }); + + if (smokeWrite) { + await request('/api/videos', { + method: 'POST', + body: JSON.stringify({ + title: `Smoke video ${Date.now()}`, + owner: 'Deploy smoke', + sourceUrl: 'https://example.com/smoke', + status: 'draft', + }), + }); + checks.push({ name: 'registry_write', status: 'ok' }); + } + + const result = { + status: 'ok', + baseUrl, + expectedReleaseId, + checks, + durationMs: Date.now() - startedAt, + }; + + if (outputPath) { + writeFileSync(outputPath, `${JSON.stringify(result, null, 2)}\n`); + } + + console.log(JSON.stringify({ event: 'smoke_check_success', ...result })); +} + +run().catch((error) => { + const result = { + status: 'error', + baseUrl, + expectedReleaseId, + error: error instanceof Error ? error.message : String(error), + }; + if (outputPath) { + writeFileSync(outputPath, `${JSON.stringify(result, null, 2)}\n`); + } + console.error(JSON.stringify({ event: 'smoke_check_failure', ...result })); + process.exit(1); +}); diff --git a/deploy/scripts/write-release-manifest.mjs b/deploy/scripts/write-release-manifest.mjs new file mode 100644 index 0000000..9d3f7f4 --- /dev/null +++ b/deploy/scripts/write-release-manifest.mjs @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs'; + +function requireEnv(name) { + const value = process.env[name]; + if (!value) { + throw new Error(`${name} is required`); + } + return value; +} + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')); +} + +const smoke = readJson(requireEnv('SMOKE_RESULT_PATH')); + +const manifest = { + commitSha: requireEnv('COMMIT_SHA'), + releaseId: requireEnv('RELEASE_ID'), + webImageDigest: requireEnv('WEB_IMAGE_DIGEST'), + deployTimestamp: new Date().toISOString(), + smokeResult: smoke, + workflowRunId: requireEnv('WORKFLOW_RUN_ID'), +}; + +if (!/@sha256:[a-f0-9]{64}$/i.test(manifest.webImageDigest)) { + throw new Error('webImageDigest must be digest-pinned'); +} + +console.log(JSON.stringify(manifest, null, 2)); diff --git a/deploy/swarm/docker-stack.yml b/deploy/swarm/docker-stack.yml new file mode 100644 index 0000000..b21cf78 --- /dev/null +++ b/deploy/swarm/docker-stack.yml @@ -0,0 +1,58 @@ +version: "3.9" + +services: + web: + image: ${WEB_IMAGE} + environment: + NODE_ENV: production + APP_ENV: ${ENVIRONMENT} + PORT: "3000" + VIDEOREG_DB_FILE: /data/videoreg.json + VIDEOREG_DB_MEMORY_TTL_MS: ${VIDEOREG_DB_MEMORY_TTL_MS:-3600000} + VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS: ${VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS:-3600} + COMMIT_SHA: ${COMMIT_SHA} + RELEASE_ID: ${RELEASE_ID} + REGION: ${REGION:-} + TEAM_ID: ${TEAM_ID:-} + volumes: + - videoreg_data:/data + networks: + traefik: {} + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health/ready >/dev/null || exit 1"] + interval: 10s + timeout: 3s + retries: 3 + start_period: 20s + deploy: + replicas: ${WEB_REPLICAS:-1} + placement: + constraints: + - ${WEB_PLACEMENT_CONSTRAINT:-node.role == manager} + update_config: + order: start-first + parallelism: 1 + failure_action: rollback + monitor: 45s + rollback_config: + order: start-first + parallelism: 1 + monitor: 45s + restart_policy: + condition: on-failure + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_NETWORK}" + - "traefik.http.routers.${STACK_NAME}-web.rule=Host(`${PUBLIC_HOST}`) && PathPrefix(`/`)" + - "traefik.http.routers.${STACK_NAME}-web.entrypoints=${TRAEFIK_ENTRYPOINTS:-websecure}" + - "traefik.http.routers.${STACK_NAME}-web.tls=${TRAEFIK_TLS:-true}" + - "traefik.http.services.${STACK_NAME}-web.loadbalancer.server.port=3000" + stop_grace_period: 30s + +volumes: + videoreg_data: + +networks: + traefik: + external: true + name: ${TRAEFIK_NETWORK} diff --git a/next-env.d.ts b/next-env.d.ts new file mode 100644 index 0000000..9edff1c --- /dev/null +++ b/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +import "./.next/types/routes.d.ts"; + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/next.config.ts b/next.config.ts new file mode 100644 index 0000000..c37b060 --- /dev/null +++ b/next.config.ts @@ -0,0 +1,10 @@ +import type { NextConfig } from 'next'; + +const nextConfig: NextConfig = { + output: 'standalone', + turbopack: { + root: process.cwd(), + }, +}; + +export default nextConfig; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..8219c1a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2587 @@ +{ + "name": "videoreg", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "videoreg", + "version": "0.1.0", + "dependencies": { + "next": "16.2.4", + "react": "19.2.5", + "react-dom": "19.2.5" + }, + "devDependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@tailwindcss/postcss": "4.2.4", + "@types/node": "24.10.1", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "tailwindcss": "4.2.4", + "typescript": "5.9.3", + "vitest": "4.1.5" + }, + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://npm.yandex-team.ru/@alloc%2fquick-lru/-/quick-lru-5.2.0.tgz?rbtorrent=e0ab50933e60c2c6f57f6cb5fadcde27aa0a1d3e", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://npm.yandex-team.ru/@emnapi%2fcore/-/core-1.10.0.tgz?rbtorrent=", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://npm.yandex-team.ru/@emnapi%2fruntime/-/runtime-1.10.0.tgz?rbtorrent=", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "devOptional": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://npm.yandex-team.ru/@emnapi%2fwasi-threads/-/wasi-threads-1.2.1.tgz?rbtorrent=", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://npm.yandex-team.ru/@img%2fcolour/-/colour-1.1.0.tgz?rbtorrent=", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz?rbtorrent=", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz?rbtorrent=", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz?rbtorrent=", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz?rbtorrent=", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz?rbtorrent=", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz?rbtorrent=", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz?rbtorrent=", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz?rbtorrent=", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz?rbtorrent=", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz?rbtorrent=", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-wasm32/-/sharp-wasm32-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/@img%2fsharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://npm.yandex-team.ru/@jridgewell%2fgen-mapping/-/gen-mapping-0.3.13.tgz?rbtorrent=", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://npm.yandex-team.ru/@jridgewell%2fremapping/-/remapping-2.3.5.tgz?rbtorrent=", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://npm.yandex-team.ru/@jridgewell%2fresolve-uri/-/resolve-uri-3.1.2.tgz?rbtorrent=", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://npm.yandex-team.ru/@jridgewell%2fsourcemap-codec/-/sourcemap-codec-1.5.5.tgz?rbtorrent=", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://npm.yandex-team.ru/@jridgewell%2ftrace-mapping/-/trace-mapping-0.3.31.tgz?rbtorrent=", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://npm.yandex-team.ru/@napi-rs%2fwasm-runtime/-/wasm-runtime-1.1.4.tgz?rbtorrent=", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@next/env": { + "version": "16.2.4", + "resolved": "https://npm.yandex-team.ru/@next%2fenv/-/env-16.2.4.tgz?rbtorrent=", + "integrity": "sha512-dKkkOzOSwFYe5RX6y26fZgkSpVAlIOJKQHIiydQcrWH6y/97+RceSOAdjZ14Qa3zLduVUy0TXcn+EiM6t4rPgw==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.4", + "resolved": "https://npm.yandex-team.ru/@next%2fswc-darwin-arm64/-/swc-darwin-arm64-16.2.4.tgz?rbtorrent=", + "integrity": "sha512-OXTFFox5EKN1Ym08vfrz+OXxmCcEjT4SFMbNRsWZE99dMqt2Kcusl5MqPXcW232RYkMLQTy0hqgAMEsfEd/l2A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.4", + "resolved": "https://npm.yandex-team.ru/@next%2fswc-darwin-x64/-/swc-darwin-x64-16.2.4.tgz?rbtorrent=", + "integrity": "sha512-XhpVnUfmYWvD3YrXu55XdcAkQtOnvaI6wtQa8fuF5fGoKoxIUZ0kWPtcOfqJEWngFF/lOS9l3+O9CcownhiQxQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.4", + "resolved": "https://npm.yandex-team.ru/@next%2fswc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.4.tgz?rbtorrent=", + "integrity": "sha512-Mx/tjlNA3G8kg14QvuGAJ4xBwPk1tUHq56JxZ8CXnZwz1Etz714soCEzGQQzVMz4bEnGPowzkV6Xrp6wAkEWOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.4", + "resolved": "https://npm.yandex-team.ru/@next%2fswc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.4.tgz?rbtorrent=", + "integrity": "sha512-iVMMp14514u7Nup2umQS03nT/bN9HurK8ufylC3FZNykrwjtx7V1A7+4kvhbDSCeonTVqV3Txnv0Lu+m2oDXNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.4", + "resolved": "https://npm.yandex-team.ru/@next%2fswc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.4.tgz?rbtorrent=", + "integrity": "sha512-EZOvm1aQWgnI/N/xcWOlnS3RQBk0VtVav5Zo7n4p0A7UKyTDx047k8opDbXgBpHl4CulRqRfbw3QrX2w5UOXMQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.4", + "resolved": "https://npm.yandex-team.ru/@next%2fswc-linux-x64-musl/-/swc-linux-x64-musl-16.2.4.tgz?rbtorrent=", + "integrity": "sha512-h9FxsngCm9cTBf71AR4fGznDEDx1hS7+kSEiIRjq5kO1oXWm07DxVGZjCvk0SGx7TSjlUqhI8oOyz7NfwAdPoA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.4", + "resolved": "https://npm.yandex-team.ru/@next%2fswc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.4.tgz?rbtorrent=", + "integrity": "sha512-3NdJV5OXMSOeJYijX+bjaLge3mJBlh4ybydbT4GFoB/2hAojWHtMhl3CYlYoMrjPuodp0nzFVi4Tj2+WaMg+Ow==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.4", + "resolved": "https://npm.yandex-team.ru/@next%2fswc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.4.tgz?rbtorrent=", + "integrity": "sha512-kMVGgsqhO5YTYODD9IPGGhA6iprWidQckK3LmPeW08PIFENRmgfb4MjXHO+p//d+ts2rpjvK5gXWzXSMrPl9cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.127.0", + "resolved": "https://npm.yandex-team.ru/@oxc-project%2ftypes/-/types-0.127.0.tgz?rbtorrent=", + "integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fbinding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/@rolldown%2fpluginutils/-/pluginutils-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://npm.yandex-team.ru/@standard-schema%2fspec/-/spec-1.1.0.tgz?rbtorrent=", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://npm.yandex-team.ru/@swc%2fhelpers/-/helpers-0.5.15.tgz?rbtorrent=", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2fnode/-/node-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2foxide/-/oxide-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "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" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2foxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2foxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2foxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2foxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2foxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2foxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2foxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2foxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2foxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2foxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2foxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2foxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/@tailwindcss%2fpostcss/-/postcss-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", + "postcss": "^8.5.6", + "tailwindcss": "4.2.4" + } + }, + "node_modules/@tailwindcss/postcss/node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://npm.yandex-team.ru/postcss/-/postcss-8.5.13.tgz?rbtorrent=", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://npm.yandex-team.ru/@tybys%2fwasm-util/-/wasm-util-0.10.1.tgz?rbtorrent=", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://npm.yandex-team.ru/@types%2fchai/-/chai-5.2.3.tgz?rbtorrent=", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://npm.yandex-team.ru/@types%2fdeep-eql/-/deep-eql-4.0.2.tgz?rbtorrent=86b21a6951ed2f4f095bfc44ad099f52e96a540f", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://npm.yandex-team.ru/@types%2festree/-/estree-1.0.8.tgz?rbtorrent=", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.1", + "resolved": "https://npm.yandex-team.ru/@types%2fnode/-/node-24.10.1.tgz?rbtorrent=", + "integrity": "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://npm.yandex-team.ru/@types%2freact/-/react-19.2.14.tgz?rbtorrent=", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://npm.yandex-team.ru/@types%2freact-dom/-/react-dom-19.2.3.tgz?rbtorrent=", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.5", + "resolved": "https://npm.yandex-team.ru/@vitest%2fexpect/-/expect-4.1.5.tgz?rbtorrent=", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.5", + "resolved": "https://npm.yandex-team.ru/@vitest%2fmocker/-/mocker-4.1.5.tgz?rbtorrent=", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.5", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.5", + "resolved": "https://npm.yandex-team.ru/@vitest%2fpretty-format/-/pretty-format-4.1.5.tgz?rbtorrent=", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.5", + "resolved": "https://npm.yandex-team.ru/@vitest%2frunner/-/runner-4.1.5.tgz?rbtorrent=", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.5", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.5", + "resolved": "https://npm.yandex-team.ru/@vitest%2fsnapshot/-/snapshot-4.1.5.tgz?rbtorrent=", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.5", + "resolved": "https://npm.yandex-team.ru/@vitest%2fspy/-/spy-4.1.5.tgz?rbtorrent=", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.5", + "resolved": "https://npm.yandex-team.ru/@vitest%2futils/-/utils-4.1.5.tgz?rbtorrent=", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.5", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://npm.yandex-team.ru/assertion-error/-/assertion-error-2.0.1.tgz?rbtorrent=e44e2c582fce4c4572f92579fb25d52bc09ae144", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.25", + "resolved": "https://npm.yandex-team.ru/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz?rbtorrent=", + "integrity": "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://npm.yandex-team.ru/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz?rbtorrent=", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://npm.yandex-team.ru/chai/-/chai-6.2.2.tgz?rbtorrent=", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://npm.yandex-team.ru/client-only/-/client-only-0.0.1.tgz?rbtorrent=36b9ca4d2b7c37c1c85069c7059d07dd68f648e4", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://npm.yandex-team.ru/convert-source-map/-/convert-source-map-2.0.0.tgz?rbtorrent=8d247ba4886ecef994137a086a4d9817b81854db", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://npm.yandex-team.ru/csstype/-/csstype-3.2.3.tgz?rbtorrent=", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://npm.yandex-team.ru/detect-libc/-/detect-libc-2.1.2.tgz?rbtorrent=", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.0", + "resolved": "https://npm.yandex-team.ru/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz?rbtorrent=", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://npm.yandex-team.ru/es-module-lexer/-/es-module-lexer-2.1.0.tgz?rbtorrent=", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://npm.yandex-team.ru/estree-walker/-/estree-walker-3.0.3.tgz?rbtorrent=5c4a77cc1271e3f8348b7ca0183ec2896986ca35", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://npm.yandex-team.ru/expect-type/-/expect-type-1.3.0.tgz?rbtorrent=", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://npm.yandex-team.ru/fdir/-/fdir-6.5.0.tgz?rbtorrent=", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://npm.yandex-team.ru/fsevents/-/fsevents-2.3.3.tgz?rbtorrent=af48e653c1b69837ab1b663db0e3cccbadea9099", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://npm.yandex-team.ru/graceful-fs/-/graceful-fs-4.2.11.tgz?rbtorrent=251aa13f9ef4151aff391872f25b06d079421837", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://npm.yandex-team.ru/jiti/-/jiti-2.6.1.tgz?rbtorrent=", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://npm.yandex-team.ru/lightningcss/-/lightningcss-1.32.0.tgz?rbtorrent=", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "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" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://npm.yandex-team.ru/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz?rbtorrent=", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://npm.yandex-team.ru/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz?rbtorrent=", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://npm.yandex-team.ru/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz?rbtorrent=", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://npm.yandex-team.ru/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz?rbtorrent=", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://npm.yandex-team.ru/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz?rbtorrent=", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://npm.yandex-team.ru/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz?rbtorrent=", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://npm.yandex-team.ru/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz?rbtorrent=", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://npm.yandex-team.ru/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz?rbtorrent=", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://npm.yandex-team.ru/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz?rbtorrent=", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://npm.yandex-team.ru/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz?rbtorrent=", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://npm.yandex-team.ru/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz?rbtorrent=", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://npm.yandex-team.ru/magic-string/-/magic-string-0.30.21.tgz?rbtorrent=", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://npm.yandex-team.ru/nanoid/-/nanoid-3.3.12.tgz?rbtorrent=", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.2.4", + "resolved": "https://npm.yandex-team.ru/next/-/next-16.2.4.tgz?rbtorrent=", + "integrity": "sha512-kPvz56wF5frc+FxlHI5qnklCzbq53HTwORaWBGdT0vNoKh1Aya9XC8aPauH4NJxqtzbWsS5mAbctm4cr+EkQ2Q==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.4", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "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" + }, + "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_modules/obug": { + "version": "2.1.1", + "resolved": "https://npm.yandex-team.ru/obug/-/obug-2.1.1.tgz?rbtorrent=", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://npm.yandex-team.ru/pathe/-/pathe-2.0.3.tgz?rbtorrent=", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://npm.yandex-team.ru/picocolors/-/picocolors-1.1.1.tgz?rbtorrent=", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://npm.yandex-team.ru/picomatch/-/picomatch-4.0.4.tgz?rbtorrent=", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://npm.yandex-team.ru/postcss/-/postcss-8.4.31.tgz?rbtorrent=217e20884324e43b47ce44404002fccc49a6ab44", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://npm.yandex-team.ru/react/-/react-19.2.5.tgz?rbtorrent=", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://npm.yandex-team.ru/react-dom/-/react-dom-19.2.5.tgz?rbtorrent=", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.17", + "resolved": "https://npm.yandex-team.ru/rolldown/-/rolldown-1.0.0-rc.17.tgz?rbtorrent=", + "integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.127.0", + "@rolldown/pluginutils": "1.0.0-rc.17" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.17", + "@rolldown/binding-darwin-x64": "1.0.0-rc.17", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.17", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.17", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.17", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.17", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://npm.yandex-team.ru/scheduler/-/scheduler-0.27.0.tgz?rbtorrent=", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://npm.yandex-team.ru/semver/-/semver-7.7.4.tgz?rbtorrent=", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://npm.yandex-team.ru/sharp/-/sharp-0.34.5.tgz?rbtorrent=", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "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" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://npm.yandex-team.ru/siginfo/-/siginfo-2.0.0.tgz?rbtorrent=c81f36a5482caeab614c579277ba7d9410225594", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://npm.yandex-team.ru/source-map-js/-/source-map-js-1.2.1.tgz?rbtorrent=", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://npm.yandex-team.ru/stackback/-/stackback-0.0.2.tgz?rbtorrent=bdf62245e16001accd64dbf9710961fd2a4ec3d6", + "integrity": "sha1-Gsig2Ug4SNFpXkGLbQMaPDzmjjs=", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://npm.yandex-team.ru/std-env/-/std-env-4.1.0.tgz?rbtorrent=", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://npm.yandex-team.ru/styled-jsx/-/styled-jsx-5.1.6.tgz?rbtorrent=", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "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 + } + } + }, + "node_modules/tailwindcss": { + "version": "4.2.4", + "resolved": "https://npm.yandex-team.ru/tailwindcss/-/tailwindcss-4.2.4.tgz?rbtorrent=", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://npm.yandex-team.ru/tapable/-/tapable-2.3.3.tgz?rbtorrent=", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://npm.yandex-team.ru/tinybench/-/tinybench-2.9.0.tgz?rbtorrent=", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.1.2", + "resolved": "https://npm.yandex-team.ru/tinyexec/-/tinyexec-1.1.2.tgz?rbtorrent=", + "integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://npm.yandex-team.ru/tinyglobby/-/tinyglobby-0.2.16.tgz?rbtorrent=", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://npm.yandex-team.ru/tinyrainbow/-/tinyrainbow-3.1.0.tgz?rbtorrent=", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://npm.yandex-team.ru/tslib/-/tslib-2.8.1.tgz?rbtorrent=", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://npm.yandex-team.ru/typescript/-/typescript-5.9.3.tgz?rbtorrent=", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://npm.yandex-team.ru/undici-types/-/undici-types-7.16.0.tgz?rbtorrent=", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.0.10", + "resolved": "https://npm.yandex-team.ru/vite/-/vite-8.0.10.tgz?rbtorrent=", + "integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.10", + "rolldown": "1.0.0-rc.17", + "tinyglobby": "^0.2.16" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://npm.yandex-team.ru/postcss/-/postcss-8.5.13.tgz?rbtorrent=", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/vitest": { + "version": "4.1.5", + "resolved": "https://npm.yandex-team.ru/vitest/-/vitest-4.1.5.tgz?rbtorrent=", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://npm.yandex-team.ru/why-is-node-running/-/why-is-node-running-2.3.0.tgz?rbtorrent=", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ac719fa --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "videoreg", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "ci:quality": "npm run typecheck && npm test && npm run build" + }, + "dependencies": { + "next": "16.2.4", + "react": "19.2.5", + "react-dom": "19.2.5" + }, + "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" + }, + "devDependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@tailwindcss/postcss": "4.2.4", + "@types/node": "24.10.1", + "@types/react": "19.2.14", + "@types/react-dom": "19.2.3", + "tailwindcss": "4.2.4", + "typescript": "5.9.3", + "vitest": "4.1.5" + } +} diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/public/.gitkeep @@ -0,0 +1 @@ + diff --git a/src/app/api/videos/route.ts b/src/app/api/videos/route.ts new file mode 100644 index 0000000..60cf2b3 --- /dev/null +++ b/src/app/api/videos/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from 'next/server'; +import { revalidatePath, revalidateTag } from 'next/cache'; +import { getCachedVideoDashboard, VIDEO_COLLECTION_TAG } from '@/entities/video/api/videoCache'; +import { addVideo } from '@/entities/video/api/videoRepository'; +import { normalizeVideoDraft } from '@/entities/video/model/video'; + +export const revalidate = 3600; + +export async function GET() { + const dashboard = await getCachedVideoDashboard(); + + return NextResponse.json(dashboard, { + headers: { + 'cache-control': 'public, max-age=60, s-maxage=3600, stale-while-revalidate=86400', + }, + }); +} + +export async function POST(request: Request) { + const body = (await request.json().catch(() => undefined)) as + | { title?: string; owner?: string; sourceUrl?: string; status?: string } + | undefined; + + const video = await addVideo(normalizeVideoDraft(body ?? {})); + revalidateTag(VIDEO_COLLECTION_TAG, { expire: 0 }); + revalidatePath('/'); + + return NextResponse.json({ video }, { status: 201 }); +} diff --git a/src/app/globals.css b/src/app/globals.css new file mode 100644 index 0000000..cece514 --- /dev/null +++ b/src/app/globals.css @@ -0,0 +1,308 @@ +:root { + color-scheme: light; + --bg: #f6f6f3; + --panel: #ffffff; + --panel-muted: #efede7; + --text: #1f2528; + --muted: #667075; + --line: #d8d5cc; + --accent: #156f5b; + --accent-strong: #0c4f41; + --warning: #9a5c12; + --danger: #a33a3a; + --info: #345f9f; + --shadow: 0 10px 24px rgb(31 37 40 / 8%); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +button, +input, +select { + font: inherit; +} + +.page-shell { + width: min(1180px, calc(100% - 32px)); + margin: 0 auto; + padding: 24px 0 40px; +} + +.topbar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 24px; + border-bottom: 1px solid var(--line); + padding-bottom: 18px; +} + +.eyebrow { + margin: 0 0 6px; + color: var(--accent); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; +} + +h1, +h2, +h3, +p { + overflow-wrap: anywhere; +} + +h1 { + margin: 0; + font-size: 2rem; + line-height: 1.1; +} + +h2 { + margin: 0; + font-size: 1.1rem; + line-height: 1.25; +} + +h3 { + margin: 0; + font-size: 0.95rem; + line-height: 1.25; +} + +.workbench { + display: grid; + grid-template-columns: minmax(280px, 360px) minmax(0, 1fr); + gap: 20px; + align-items: start; + padding-top: 22px; +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + box-shadow: var(--shadow); +} + +.panel-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid var(--line); + padding: 16px; +} + +.panel-body { + padding: 16px; +} + +.form-grid { + display: grid; + gap: 14px; +} + +.field { + display: grid; + gap: 6px; +} + +.field span { + color: var(--muted); + font-size: 0.82rem; + font-weight: 650; +} + +.field input, +.field select { + min-height: 42px; + width: 100%; + border: 1px solid var(--line); + border-radius: 6px; + background: #fff; + color: var(--text); + padding: 9px 10px; +} + +.field input:focus, +.field select:focus { + border-color: var(--accent); + outline: 3px solid rgb(21 111 91 / 16%); +} + +.submit-button { + min-height: 42px; + border: 0; + border-radius: 6px; + background: var(--accent); + color: #fff; + cursor: pointer; + font-weight: 750; +} + +.submit-button:hover { + background: var(--accent-strong); +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(110px, 1fr)); + gap: 10px; +} + +.metric { + min-height: 76px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel-muted); + padding: 12px; +} + +.metric span { + display: block; + color: var(--muted); + font-size: 0.76rem; + font-weight: 700; + text-transform: uppercase; +} + +.metric strong { + display: block; + margin-top: 7px; + font-size: 1.45rem; +} + +.video-list { + display: grid; + gap: 10px; + margin-top: 16px; +} + +.video-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fff; + padding: 14px; +} + +.video-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.pill { + min-height: 24px; + border-radius: 999px; + background: var(--panel-muted); + color: var(--muted); + padding: 4px 9px; + font-size: 0.76rem; + font-weight: 700; +} + +.pill.status-draft { + color: var(--warning); +} + +.pill.status-published { + color: var(--accent); +} + +.pill.status-archived { + color: var(--danger); +} + +.cache-panel { + display: grid; + grid-template-columns: repeat(3, auto); + gap: 8px; + justify-content: end; +} + +.cache-item { + min-height: 42px; + border: 1px solid var(--line); + border-radius: 8px; + background: var(--panel); + padding: 7px 10px; +} + +.cache-item span { + display: block; + color: var(--muted); + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; +} + +.cache-item strong { + display: block; + margin-top: 2px; + font-size: 0.84rem; +} + +.empty-state { + border: 1px dashed var(--line); + border-radius: 8px; + color: var(--muted); + padding: 22px; + text-align: center; +} + +@media (max-width: 900px) { + .topbar, + .workbench { + grid-template-columns: 1fr; + } + + .topbar { + display: grid; + } + + .cache-panel { + justify-content: stretch; + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + .stats-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + .page-shell { + width: min(100% - 20px, 1180px); + padding-top: 16px; + } + + h1 { + font-size: 1.55rem; + } + + .cache-panel, + .stats-grid, + .video-card { + grid-template-columns: 1fr; + } +} diff --git a/src/app/health/live/route.ts b/src/app/health/live/route.ts new file mode 100644 index 0000000..47b5e77 --- /dev/null +++ b/src/app/health/live/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +export function GET() { + return NextResponse.json({ + status: 'ok', + service: 'videoreg-web', + metadata: { + commitSha: process.env.COMMIT_SHA ?? 'local', + releaseId: process.env.RELEASE_ID ?? 'local', + }, + }); +} diff --git a/src/app/health/ready/route.ts b/src/app/health/ready/route.ts new file mode 100644 index 0000000..bbc00c7 --- /dev/null +++ b/src/app/health/ready/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server'; +import { listVideos } from '@/entities/video/api/videoRepository'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + await listVideos(); + + return NextResponse.json({ + status: 'ok', + service: 'videoreg-web', + metadata: { + commitSha: process.env.COMMIT_SHA ?? 'local', + releaseId: process.env.RELEASE_ID ?? 'local', + }, + }); +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..25d2435 --- /dev/null +++ b/src/app/layout.tsx @@ -0,0 +1,19 @@ +import type { Metadata } from 'next'; +import './globals.css'; + +export const metadata: Metadata = { + title: 'Video Registry', + description: 'Operational video registry with primitive storage and aggressive caching.', +}; + +type RootLayoutProps = { + children: React.ReactNode; +}; + +export default function RootLayout({ children }: RootLayoutProps) { + return ( + + {children} + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..c91fe5d --- /dev/null +++ b/src/app/page.tsx @@ -0,0 +1,27 @@ +import { getCachedVideoDashboard } from '@/entities/video/api/videoCache'; +import { RegisterVideoForm } from '@/features/register-video/ui/RegisterVideoForm'; +import { CachePanel } from '@/widgets/cache-panel/ui/CachePanel'; +import { VideoDashboard } from '@/widgets/video-dashboard/ui/VideoDashboard'; + +export const revalidate = 3600; + +export default async function Home() { + const dashboard = await getCachedVideoDashboard(); + + return ( +
+
+
+

videoreg.ru

+

Video Registry

+
+ +
+ +
+ + +
+
+ ); +} diff --git a/src/entities/video/api/videoCache.ts b/src/entities/video/api/videoCache.ts new file mode 100644 index 0000000..764d4e7 --- /dev/null +++ b/src/entities/video/api/videoCache.ts @@ -0,0 +1,29 @@ +import 'server-only'; + +import { unstable_cache } from 'next/cache'; +import { DB_MEMORY_TTL_MS, NEXT_CACHE_REVALIDATE_SECONDS } from '@/shared/config/env'; +import { calculateVideoStats } from '@/entities/video/model/video'; +import { listVideos } from './videoRepository'; + +export const VIDEO_COLLECTION_TAG = 'video-collection'; + +export const getCachedVideoDashboard = unstable_cache( + async () => { + const videos = await listVideos(); + + return { + videos, + stats: calculateVideoStats(videos), + cache: { + generatedAt: new Date().toISOString(), + nextRevalidateSeconds: NEXT_CACHE_REVALIDATE_SECONDS, + memoryTtlMs: DB_MEMORY_TTL_MS, + }, + }; + }, + ['video-dashboard-v1'], + { + revalidate: NEXT_CACHE_REVALIDATE_SECONDS, + tags: [VIDEO_COLLECTION_TAG], + }, +); diff --git a/src/entities/video/api/videoRepository.ts b/src/entities/video/api/videoRepository.ts new file mode 100644 index 0000000..4c4f288 --- /dev/null +++ b/src/entities/video/api/videoRepository.ts @@ -0,0 +1,21 @@ +import 'server-only'; + +import { createVideo } from '@/entities/video/model/video'; +import type { VideoDraft } from '@/entities/video/model/types'; +import { readPrimitiveDb, writePrimitiveDb } from '@/shared/db/primitiveJsonDb'; + +export async function listVideos() { + const db = await readPrimitiveDb(); + return [...db.videos].sort((left, right) => right.createdAt.localeCompare(left.createdAt)); +} + +export async function addVideo(draft: VideoDraft) { + const video = createVideo(draft); + + await writePrimitiveDb((current) => ({ + ...current, + videos: [video, ...current.videos], + })); + + return video; +} diff --git a/src/entities/video/model/types.ts b/src/entities/video/model/types.ts new file mode 100644 index 0000000..ae46f2d --- /dev/null +++ b/src/entities/video/model/types.ts @@ -0,0 +1,37 @@ +export const videoStatuses = ['draft', 'published', 'archived'] as const; + +export type VideoStatus = (typeof videoStatuses)[number]; + +export type Video = { + id: string; + title: string; + owner: string; + sourceUrl: string; + status: VideoStatus; + createdAt: string; + updatedAt: string; +}; + +export type VideoDraft = { + title: string; + owner: string; + sourceUrl: string; + status: VideoStatus; +}; + +export type VideoDashboardStats = { + total: number; + draft: number; + published: number; + archived: number; +}; + +export type VideoDashboardData = { + videos: Video[]; + stats: VideoDashboardStats; + cache: { + generatedAt: string; + nextRevalidateSeconds: number; + memoryTtlMs: number; + }; +}; diff --git a/src/entities/video/model/video.test.ts b/src/entities/video/model/video.test.ts new file mode 100644 index 0000000..60f81b6 --- /dev/null +++ b/src/entities/video/model/video.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { calculateVideoStats, normalizeVideoDraft } from './video'; +import type { Video } from './types'; + +describe('video model', () => { + it('normalizes sparse form input', () => { + expect( + normalizeVideoDraft({ + title: ' Demo reel ', + owner: '', + sourceUrl: 'not a url', + status: 'unexpected', + }), + ).toEqual({ + title: 'Demo reel', + owner: 'Unassigned', + sourceUrl: 'https://example.com/video', + status: 'draft', + }); + }); + + it('counts videos by status', () => { + const base = { + id: 'id', + title: 'Title', + owner: 'Ops', + sourceUrl: 'https://example.com/video', + createdAt: '2026-05-16T00:00:00.000Z', + updatedAt: '2026-05-16T00:00:00.000Z', + }; + const videos: Video[] = [ + { ...base, id: 'draft', status: 'draft' }, + { ...base, id: 'published', status: 'published' }, + { ...base, id: 'archived', status: 'archived' }, + { ...base, id: 'published-2', status: 'published' }, + ]; + + expect(calculateVideoStats(videos)).toEqual({ + total: 4, + draft: 1, + published: 2, + archived: 1, + }); + }); +}); diff --git a/src/entities/video/model/video.ts b/src/entities/video/model/video.ts new file mode 100644 index 0000000..02b521b --- /dev/null +++ b/src/entities/video/model/video.ts @@ -0,0 +1,68 @@ +import { videoStatuses, type Video, type VideoDashboardStats, type VideoDraft, type VideoStatus } from './types'; + +const fallbackUrl = 'https://example.com/video'; + +function normalizeText(value: string, fallback: string) { + const normalized = value.trim().replace(/\s+/g, ' '); + return normalized.length > 0 ? normalized : fallback; +} + +function normalizeUrl(value: string) { + const normalized = value.trim(); + if (normalized.length === 0) return fallbackUrl; + + try { + return new URL(normalized).toString(); + } catch { + return fallbackUrl; + } +} + +export function isVideoStatus(value: string): value is VideoStatus { + return videoStatuses.includes(value as VideoStatus); +} + +export function normalizeVideoDraft(input: { + title?: string; + owner?: string; + sourceUrl?: string; + status?: string; +}): VideoDraft { + return { + title: normalizeText(input.title ?? '', 'Untitled video'), + owner: normalizeText(input.owner ?? '', 'Unassigned'), + sourceUrl: normalizeUrl(input.sourceUrl ?? ''), + status: input.status && isVideoStatus(input.status) ? input.status : 'draft', + }; +} + +export function createVideoId(now = new Date()) { + const suffix = Math.random().toString(36).slice(2, 8); + return `video_${now.getTime().toString(36)}_${suffix}`; +} + +export function createVideo(draft: VideoDraft, now = new Date()): Video { + const timestamp = now.toISOString(); + return { + ...draft, + id: createVideoId(now), + createdAt: timestamp, + updatedAt: timestamp, + }; +} + +export function calculateVideoStats(videos: Video[]): VideoDashboardStats { + return videos.reduce( + (stats, video) => ({ + ...stats, + total: stats.total + 1, + [video.status]: stats[video.status] + 1, + }), + { + total: 0, + draft: 0, + published: 0, + archived: 0, + }, + ); +} diff --git a/src/features/register-video/model/actions.ts b/src/features/register-video/model/actions.ts new file mode 100644 index 0000000..d16500d --- /dev/null +++ b/src/features/register-video/model/actions.ts @@ -0,0 +1,20 @@ +'use server'; + +import { revalidatePath, updateTag } from 'next/cache'; +import { VIDEO_COLLECTION_TAG } from '@/entities/video/api/videoCache'; +import { addVideo } from '@/entities/video/api/videoRepository'; +import { normalizeVideoDraft } from '@/entities/video/model/video'; + +export async function registerVideoAction(formData: FormData) { + const draft = normalizeVideoDraft({ + title: String(formData.get('title') ?? ''), + owner: String(formData.get('owner') ?? ''), + sourceUrl: String(formData.get('sourceUrl') ?? ''), + status: String(formData.get('status') ?? ''), + }); + + await addVideo(draft); + + updateTag(VIDEO_COLLECTION_TAG); + revalidatePath('/'); +} diff --git a/src/features/register-video/ui/RegisterVideoForm.tsx b/src/features/register-video/ui/RegisterVideoForm.tsx new file mode 100644 index 0000000..6a64a69 --- /dev/null +++ b/src/features/register-video/ui/RegisterVideoForm.tsx @@ -0,0 +1,36 @@ +import { registerVideoAction } from '../model/actions'; + +export function RegisterVideoForm() { + return ( +
+
+

Register video

+
+
+ + + + + +
+
+ ); +} diff --git a/src/shared/config/env.ts b/src/shared/config/env.ts new file mode 100644 index 0000000..da9cae5 --- /dev/null +++ b/src/shared/config/env.ts @@ -0,0 +1,5 @@ +export const APP_NAME = 'videoreg'; +export const DB_MEMORY_TTL_MS = Number(process.env.VIDEOREG_DB_MEMORY_TTL_MS ?? 60 * 60 * 1000); +export const NEXT_CACHE_REVALIDATE_SECONDS = Number( + process.env.VIDEOREG_NEXT_CACHE_REVALIDATE_SECONDS ?? 60 * 60, +); diff --git a/src/shared/db/primitiveJsonDb.ts b/src/shared/db/primitiveJsonDb.ts new file mode 100644 index 0000000..1db5662 --- /dev/null +++ b/src/shared/db/primitiveJsonDb.ts @@ -0,0 +1,134 @@ +import 'server-only'; + +import { mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { DB_MEMORY_TTL_MS } from '@/shared/config/env'; +import type { Video } from '@/entities/video/model/types'; + +type PrimitiveDbSchema = { + videos: Video[]; +}; + +type MemorySnapshot = { + loadedAt: number; + fileMtimeMs: number; + data: PrimitiveDbSchema; +}; + +const defaultSeed: PrimitiveDbSchema = { + videos: [ + { + id: 'video_seed_launch', + title: 'Launch recording', + owner: 'Ops', + sourceUrl: 'https://example.com/launch-recording', + status: 'published', + createdAt: '2026-05-16T09:00:00.000Z', + updatedAt: '2026-05-16T09:00:00.000Z', + }, + { + id: 'video_seed_cut', + title: 'Editing backlog', + owner: 'Media', + sourceUrl: 'https://example.com/editing-backlog', + status: 'draft', + createdAt: '2026-05-16T10:00:00.000Z', + updatedAt: '2026-05-16T10:00:00.000Z', + }, + ], +}; + +let memorySnapshot: MemorySnapshot | undefined; +let writeQueue = Promise.resolve(); + +function getDbFilePath() { + if (process.env.VIDEOREG_DB_FILE) { + return process.env.VIDEOREG_DB_FILE; + } + + return path.join(/* turbopackIgnore: true */ process.cwd(), 'data', 'videoreg.json'); +} + +async function ensureDbFile(filePath: string) { + await mkdir(path.dirname(filePath), { recursive: true }); + + try { + await stat(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + throw error; + } + await writeFile(filePath, `${JSON.stringify(defaultSeed, null, 2)}\n`, 'utf8'); + } +} + +function parseSchema(raw: string): PrimitiveDbSchema { + const parsed = JSON.parse(raw) as Partial; + return { + videos: Array.isArray(parsed.videos) ? parsed.videos : [], + }; +} + +async function loadFromDisk(filePath: string): Promise { + await ensureDbFile(filePath); + const [fileStat, raw] = await Promise.all([stat(filePath), readFile(filePath, 'utf8')]); + const snapshot = { + loadedAt: Date.now(), + fileMtimeMs: fileStat.mtimeMs, + data: parseSchema(raw), + }; + memorySnapshot = snapshot; + return snapshot; +} + +export async function readPrimitiveDb() { + const filePath = getDbFilePath(); + const now = Date.now(); + + if (memorySnapshot && now - memorySnapshot.loadedAt < DB_MEMORY_TTL_MS) { + return memorySnapshot.data; + } + + const fileStat = await stat(filePath).catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return undefined; + throw error; + }); + + if (memorySnapshot && fileStat && fileStat.mtimeMs === memorySnapshot.fileMtimeMs) { + memorySnapshot.loadedAt = now; + return memorySnapshot.data; + } + + return (await loadFromDisk(filePath)).data; +} + +export async function writePrimitiveDb( + updater: (current: PrimitiveDbSchema) => PrimitiveDbSchema | Promise, +) { + const operation = writeQueue.then(async () => { + const filePath = getDbFilePath(); + const current = await readPrimitiveDb(); + const next = await updater(structuredClone(current)); + const temporaryPath = `${filePath}.${process.pid}.tmp`; + + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(temporaryPath, `${JSON.stringify(next, null, 2)}\n`, 'utf8'); + await rename(temporaryPath, filePath); + + const fileStat = await stat(filePath); + memorySnapshot = { + loadedAt: Date.now(), + fileMtimeMs: fileStat.mtimeMs, + data: next, + }; + + return next; + }); + + writeQueue = operation.then( + () => undefined, + () => undefined, + ); + + return operation; +} diff --git a/src/shared/lib/date.ts b/src/shared/lib/date.ts new file mode 100644 index 0000000..254775f --- /dev/null +++ b/src/shared/lib/date.ts @@ -0,0 +1,6 @@ +export function formatDateTime(value: string) { + return new Intl.DateTimeFormat('en', { + dateStyle: 'medium', + timeStyle: 'short', + }).format(new Date(value)); +} diff --git a/src/widgets/cache-panel/ui/CachePanel.tsx b/src/widgets/cache-panel/ui/CachePanel.tsx new file mode 100644 index 0000000..cac9ef1 --- /dev/null +++ b/src/widgets/cache-panel/ui/CachePanel.tsx @@ -0,0 +1,25 @@ +import type { VideoDashboardData } from '@/entities/video/model/types'; +import { formatDateTime } from '@/shared/lib/date'; + +type CachePanelProps = { + metadata: VideoDashboardData['cache']; +}; + +export function CachePanel({ metadata }: CachePanelProps) { + return ( + + ); +} diff --git a/src/widgets/video-dashboard/ui/VideoDashboard.tsx b/src/widgets/video-dashboard/ui/VideoDashboard.tsx new file mode 100644 index 0000000..c017f98 --- /dev/null +++ b/src/widgets/video-dashboard/ui/VideoDashboard.tsx @@ -0,0 +1,57 @@ +import type { VideoDashboardData } from '@/entities/video/model/types'; +import { formatDateTime } from '@/shared/lib/date'; + +type VideoDashboardProps = { + dashboard: VideoDashboardData; +}; + +export function VideoDashboard({ dashboard }: VideoDashboardProps) { + return ( +
+
+
+

Registry

+
+
+
+
+ + + + +
+ +
+ {dashboard.videos.length > 0 ? ( + dashboard.videos.map((video) => ( +
+
+

{video.title}

+
+ {video.status} + {video.owner} + {formatDateTime(video.createdAt)} +
+
+ + Source + +
+ )) + ) : ( +
No videos registered
+ )} +
+
+
+ ); +} + +function Metric({ label, value }: { label: string; value: number }) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/tasks/001-design-system.md b/tasks/001-design-system.md new file mode 100644 index 0000000..791e86d --- /dev/null +++ b/tasks/001-design-system.md @@ -0,0 +1,696 @@ +# Design System Task for Car Dashcam Website + +For: Designer + Frontend Design System Implementation Engineer +Stack: React + Next.js + Tailwind CSS +Architecture: Atomic Design + Design Tokens + Component-Driven Development + +--- + +# Product Context + +The website is focused on car video registrators (dashcams), accessories, comparisons, reviews, installation guides, and purchase flows. + +The product positioning is: + +* technologically advanced +* reliable in emergencies +* automotive premium feel +* minimal cognitive overload +* optimized for mobile-first browsing +* fast perceived loading +* visually trustworthy for expensive electronics + +The visual language should combine: + +* modern automotive UI +* Japanese/Korean automotive electronics aesthetics +* dashboard HUD inspiration +* premium marketplace clarity +* clean editorial layouts + +Avoid: + +* aggressive gaming aesthetics +* neon cyberpunk overload +* cheap AliExpress visual style +* overly corporate SaaS look +* generic ecommerce templates + +The design should feel somewhere between: + +* Tesla interface +* Blackmagic Design product pages +* Sony camera product pages +* premium automotive accessories catalog +* modern editorial tech magazine + +--- + +# Resolved Direction + +## Product Strategy + +Phase 1 should optimize for editorial trust and comparison-first decision making, not direct ecommerce conversion. + +The first proof artifact is a visual direction brief for a “Best Dashcams 2026” comparison page. Code implementation comes later, after the visual language is approved. + +The page should use: + +* one overall winner +* use-case winners for different buyer needs +* transparent scoring +* editorial override notes when real-world testing contradicts a raw score + +Default score dimensions: + +* video +* night +* parking +* reliability +* usability +* value + +Use the same six dimensions across categories, but allow category-specific weights. + +## Trust Model + +Product facts and claims must expose three public trust states: + +* verified +* claimed +* unknown + +The public UI should stay simple, but the internal data model should preserve verification method, for example: + +* measured in review +* confirmed from firmware docs +* confirmed from teardown +* confirmed by multiple sources + +Trust labels should be quiet evidence chips with an icon and short label. Details such as method, source, and date should live in tooltips, detail panels, or source notes. + +## Visual Anchor + +The primary visual anchor is premium camera/product editorial, with automotive HUD cues used secondarily. + +Real product and media photography should dominate. UI, HUD, and camera-interface details should appear only as restrained overlays or supporting metadata. + +The default theme is light-first titanium brochure with dark product/media modules and full dark-mode parity. + +## First Viewport + +The first viewport of “Best Dashcams 2026” should prioritize trust and decision shortcuts: + +* overall pick +* 3-5 use-case winners +* methodology access +* evidence labels +* concise scoring context + +Avoid leading with a purely cinematic product hero if it delays comparison intent. + +## Comparison UX + +Mobile comparison should use stacked winner cards first, followed by a horizontal comparison table with sticky spec labels. + +The comparison table should lead with a compact hardware block before outcome rows. + +Core first rows: + +* sensor +* resolution/FPS +* bitrate +* HDR +* lens/FOV +* storage +* capacitor/battery +* channels + +Then show verdict, score dimensions, known issues, price, and seller information. + +## Ecommerce Role + +Purchase CTAs should be secondary and evidence-adjacent. They should sit near price and seller information, but must not dominate winner cards or turn the page into an affiliate marketplace. + +## Visual Taste Guardrails + +Use muted, pastel, old-money color behavior. Avoid raw bright, screaming, neon, or cheap saturated colors. + +The lead accent family is desaturated petrol blue / steel blue. + +Keep semantic amber and recording red, but make them muted and low-chroma. + +Use tight geometry: + +* 4-8px radii +* hairline borders +* mostly flat surfaces +* subtle tonal layering +* restrained shadows + +Do not use: + +* neon accents +* heavy glassmorphism +* big rounded SaaS cards +* stock gradients +* loud affiliate blocks +* crypto-dashboard styling +* gaming HUD overload +* generic ecommerce templates + +## Typography Decision + +Use an editorial sans for reading and UI, with technical monospace reserved for specs. + +Name concrete font candidates in the spec: + +* UI/editorial: Geist or Inter, with system sans fallback +* technical fragments: IBM Plex Mono or Iosevka, with system monospace fallback + +--- + +# Primary Goals + +## UX Goals + +1. Make product comparison extremely easy +2. Build trust quickly +3. Reduce noise in specification-heavy interfaces +4. Support long-form technical reading +5. Make mobile usage first-class +6. Allow users to scan specs very fast +7. Visually separate verified information from marketing claims +8. Create reusable layouts for: + + * reviews + * comparisons + * guides + * catalogs + * landing pages + * product detail pages + +--- + +# Design Principles + +## 1. Information Density Without Chaos + +The system should support large amounts of technical data while remaining readable. + +Dashcam users compare: + +* bitrate +* sensor model +* HDR support +* parking mode +* CPL filters +* heat resistance +* night quality +* codecs +* Wi-Fi support +* cloud integrations + +The UI must help scanning. + +--- + +## 2. Automotive Precision + +Spacing, typography, borders, and interactions should feel engineered rather than decorative. + +Use: + +* precise spacing scales +* subtle contrast +* restrained shadows +* clean hierarchy +* predictable interaction patterns + +Avoid random rounded cards everywhere. + +--- + +## 3. “Camera Hardware” Visual Language + +Visual references: + +* camera UI +* automotive instrument clusters +* photography gear +* black anodized aluminum +* dark matte surfaces +* glass reflections +* OLED dashboard typography + +--- + +# Technical Requirements + +## Stack + +Implementation must use: + +* Tailwind CSS +* CSS variables for tokens +* React compound components +* atomic architecture +* accessibility-first approach +* responsive-first approach +* light-first default with full dark-mode parity +* SSR-safe patterns for Next.js + +--- + +# Atomic Architecture Structure + +## Atoms + +Examples: + +* Button +* Badge +* Chip +* Tag +* Typography +* PriceLabel +* RatingStars +* Divider +* Input +* Select +* Checkbox +* IconButton +* Tooltip +* StatusIndicator +* BatteryIndicator +* SensorLabel +* RecordingQualityBadge + +Requirements: + +* variants +* sizes +* semantic states +* keyboard accessibility +* token-driven styling only + +--- + +## Molecules + +Examples: + +* ProductCard +* ReviewCard +* SpecRow +* ComparisonRow +* VideoPreview +* PriceBlock +* SellerBadge +* NavigationItem +* Breadcrumbs +* SearchInput +* FilterDropdown +* StickyBuyPanel + +--- + +## Organisms + +Examples: + +* ProductComparisonTable +* ReviewHero +* ProductGallery +* SpecificationSection +* VideoSamplesCarousel +* ReviewSummary +* InstallationGuide +* ProductGrid +* RecommendationRail +* StickyMobileActions +* ReviewTOC +* Header +* Footer + +--- + +## Templates + +Examples: + +* Product Page +* Review Article +* Comparison Page +* Category Catalog +* Brand Page +* Landing Page +* Blog Article +* Search Results +* “Best Dashcams 2026” page + +--- + +# Visual Direction + +## Color System + +### Base Palette + +Primary colors should feel: + +* automotive +* metallic +* technical +* trustworthy + +Suggested direction: + +* graphite +* gunmetal +* matte black +* titanium gray +* cold white +* deep navy accents + +Accent colors: + +* desaturated petrol blue / steel blue as the lead accent family +* muted low-chroma amber for warnings, heat, caution, and parking-related states +* muted low-chroma red for recording and critical states +* success green only where meaningful + +Avoid rainbow interfaces and raw bright accents. + +--- + +## Surface System + +Need layered surfaces: + +| Level | Usage | +| -------------- | -------------------------- | +| Surface 0 | Light titanium app background | +| Surface 1 | Main editorial cards and comparison blocks | +| Surface 2 | Elevated product/media modules | +| Surface 3 | Interactive hover surfaces | +| Surface Accent | Dark product/media blocks and premium highlights | + +Light mode is the primary default. + +Light mode should feel like: + +* premium automotive brochure +* not plain white SaaS + +Dark mode must have full parity and should preserve the same hierarchy, contrast, and trust labeling. + +--- + +# Typography System + +Typography is extremely important. + +The interface must support: + +* long-form reading +* technical specs +* comparisons +* editorial content +* ecommerce CTAs + +Recommended direction: + +Headings: + +* geometric sans +* condensed possible for large titles + +Body: + +* highly readable +* neutral +* strong numeric clarity + +Monospace: + +* used for technical specs +* bitrate +* firmware +* codecs + +Potential inspirations: + +* Inter +* Geist +* IBM Plex Sans +* Iosevka for technical fragments + +Resolved direction: + +* UI/editorial: Geist or Inter, with system sans fallback +* technical fragments: IBM Plex Mono or Iosevka, with system monospace fallback +* monospace should be reserved for specs, firmware, codecs, bitrate, and compact technical metadata + +--- + +# Layout System + +## Grid + +Need: + +* 4px spacing base +* predictable responsive grid +* container system +* content widths for: + + * article reading + * comparison tables + * dense catalogs + +--- + +## Responsive Targets + +Must support: + +| Device | Priority | +| ----------------- | -------- | +| Mobile portrait | Highest | +| Mobile landscape | High | +| Tablet portrait | High | +| Desktop ultrawide | Medium | + +Special focus: + +* sticky bottom actions on mobile +* comparison tables on narrow screens +* thumb-friendly navigation + +--- + +# Motion System + +Motion should feel: + +* mechanical +* precise +* responsive + +Avoid: + +* playful animations +* bouncing +* exaggerated easing + +Allowed: + +* subtle fades +* sliding panels +* controlled transitions +* progressive disclosure + +--- + +# Icons + +Need unified icon language. + +Possible direction: + +* outline icons +* automotive dashboard inspiration +* camera hardware icons +* signal indicators +* storage/media indicators + +--- + +# Content Components + +Must support: + +## Review Components + +* pros/cons +* verdict block +* sample footage embeds +* firmware history +* heat test results +* night comparison sliders +* speed readability examples + +--- + +## Comparison Components + +Need highly optimized comparison UX: + +* sticky headers +* horizontal scroll with snap +* column pinning +* spec highlighting +* “best value” indicators + +Resolved Phase 1 behavior: + +* stacked winner cards first on mobile +* horizontal comparison table after cards +* sticky spec labels on narrow screens +* first visible table rows should be the compact hardware block: sensor, resolution/FPS, bitrate, HDR, lens/FOV, storage, capacitor/battery, channels +* outcome rows should follow the hardware block + +--- + +# Ecommerce Components + +Need: + +* affiliate blocks +* seller comparison +* availability indicators +* price history blocks +* CTA hierarchy +* coupon presentation + +Must avoid spammy affiliate aesthetics. + +--- + +# Accessibility Requirements + +Minimum: + +* WCAG AA +* keyboard navigation +* visible focus states +* semantic HTML +* proper contrast +* reduced motion support + +--- + +# Tailwind Requirements + +## Must Produce + +1. Tailwind token structure +2. Design token naming convention +3. Component API documentation +4. Variant strategy +5. Responsive rules +6. Dark/light theming system +7. Atomic folder structure +8. Figma component parity with React components + +--- + +# Deliverables + +## Design Deliverables + +* Figma design system +* typography scale +* spacing system +* icon system +* color tokens +* component library +* responsive examples +* interaction states +* accessibility annotations + +--- + +## Frontend Deliverables + +Implementation in: + +* React +* Next.js +* Tailwind CSS + +Including: + +* token-based theme +* reusable atomic components +* Storybook +* dark/light support +* mobile-first responsiveness +* typed component APIs +* zero hardcoded colors in components + +--- + +# Important Constraints + +The final result must NOT look like: + +* generic Tailwind template +* Bootstrap ecommerce +* crypto dashboard +* gaming UI +* cheap gadget marketplace + +The interface should feel: + +* premium +* calm +* technical +* engineered +* trustworthy +* fast + +Users should subconsciously feel: +“This is a serious automotive electronics platform.” + +--- + +# Suggested Initial Priority + +Phase 1: + +* typography +* colors +* spacing +* visual direction brief for “Best Dashcams 2026” +* buttons +* cards +* navigation +* product cards +* comparison table + +Phase 2: + +* article system +* review system +* ecommerce modules +* filtering system + +Phase 3: + +* motion +* advanced comparison UX +* personalization +* saved comparisons +* dashboard-like features diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..752a139 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,41 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": [ + "dom", + "dom.iterable", + "es2022" + ], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..1b0d162 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import path from 'node:path'; +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'node', + include: ['src/**/*.test.ts'], + }, + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, +});