Merge branch 'copilot/update-frontend-ui-modern-design'
This commit is contained in:
@@ -74,6 +74,11 @@ POSTGRES_PASSWORD=
|
||||
# Frontend
|
||||
VITE_SITE_DOMAIN=https://dociva.io
|
||||
VITE_SENTRY_DSN=
|
||||
INDEXNOW_KEY=
|
||||
INDEXNOW_ENDPOINT=https://www.bing.com/indexnow
|
||||
INDEXNOW_AUTO_SUBMIT=true
|
||||
INDEXNOW_STRICT=false
|
||||
INDEXNOW_FULL_SUBMIT=false
|
||||
|
||||
# Frontend Analytics / Ads (Vite)
|
||||
VITE_GA_MEASUREMENT_ID=G-XXXXXXXXXX
|
||||
@@ -85,10 +90,9 @@ VITE_ADSENSE_SLOT_HOME_TOP=1234567890
|
||||
VITE_ADSENSE_SLOT_HOME_BOTTOM=1234567891
|
||||
VITE_ADSENSE_SLOT_TOP_BANNER=1234567892
|
||||
VITE_ADSENSE_SLOT_BOTTOM_BANNER=1234567893
|
||||
VITE_CLARITY_PROJECT_ID=vzw2jb2ipq
|
||||
|
||||
# Feature Flags (set to "false" to disable a specific tool)
|
||||
FEATURE_EDITOR=true
|
||||
FEATURE_OCR=true
|
||||
FEATURE_REMOVEBG=true
|
||||
|
||||
VITE_CLARITY_PROJECT_ID=vzw2jb2ipq
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -16,6 +16,7 @@ docs/
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/build/
|
||||
frontend/.indexnow/
|
||||
.npm
|
||||
*.tsbuildinfo
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -79,6 +79,16 @@ docker-compose up --build
|
||||
- `VITE_ADSENSE_SLOT_BOTTOM_BANNER`
|
||||
- `DATABASE_PATH`
|
||||
|
||||
## 🔎 IndexNow
|
||||
|
||||
- Verification file is published at `frontend/public/718dc0aa7c7d4d3ebe71e3f97dacef9c.txt` and copied into the production build.
|
||||
- Dry-run the payload locally with `cd frontend && npm run indexnow:dry-run`.
|
||||
- Submit the current sitemap URLs manually with `cd frontend && npm run indexnow:submit`.
|
||||
- Production deploys call `scripts/deploy.sh`, which runs the same submission step after the health check when `INDEXNOW_AUTO_SUBMIT` is not disabled.
|
||||
- Successful submissions persist a local state snapshot so later deploys only notify changed or removed URLs instead of re-sending the full sitemap every time.
|
||||
- `INDEXNOW_STRICT=true` now fails the deployment when the IndexNow request fails.
|
||||
- Optional env overrides: `INDEXNOW_KEY`, `INDEXNOW_ENDPOINT`, `INDEXNOW_AUTO_SUBMIT`, `INDEXNOW_STRICT`, and `INDEXNOW_FULL_SUBMIT`.
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
```
|
||||
|
||||
@@ -137,6 +137,25 @@ services:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
target: build
|
||||
args:
|
||||
VITE_GA_MEASUREMENT_ID: ${VITE_GA_MEASUREMENT_ID:-}
|
||||
VITE_PLAUSIBLE_DOMAIN: ${VITE_PLAUSIBLE_DOMAIN:-}
|
||||
VITE_PLAUSIBLE_SRC: ${VITE_PLAUSIBLE_SRC:-https://plausible.io/js/script.js}
|
||||
VITE_GOOGLE_SITE_VERIFICATION: ${VITE_GOOGLE_SITE_VERIFICATION:-}
|
||||
VITE_ADSENSE_CLIENT_ID: ${VITE_ADSENSE_CLIENT_ID:-}
|
||||
VITE_ADSENSE_SLOT_HOME_TOP: ${VITE_ADSENSE_SLOT_HOME_TOP:-}
|
||||
VITE_ADSENSE_SLOT_HOME_BOTTOM: ${VITE_ADSENSE_SLOT_HOME_BOTTOM:-}
|
||||
VITE_ADSENSE_SLOT_TOP_BANNER: ${VITE_ADSENSE_SLOT_TOP_BANNER:-}
|
||||
VITE_ADSENSE_SLOT_BOTTOM_BANNER: ${VITE_ADSENSE_SLOT_BOTTOM_BANNER:-}
|
||||
VITE_FEATURE_EDITOR: ${VITE_FEATURE_EDITOR:-true}
|
||||
VITE_FEATURE_OCR: ${VITE_FEATURE_OCR:-true}
|
||||
VITE_FEATURE_REMOVEBG: ${VITE_FEATURE_REMOVEBG:-true}
|
||||
VITE_SITE_DOMAIN: ${VITE_SITE_DOMAIN:-}
|
||||
VITE_SENTRY_DSN: ${VITE_SENTRY_DSN:-}
|
||||
VITE_CLARITY_PROJECT_ID: ${VITE_CLARITY_PROJECT_ID:-}
|
||||
INDEXNOW_KEY: ${INDEXNOW_KEY:-}
|
||||
INDEXNOW_ENDPOINT: ${INDEXNOW_ENDPOINT:-https://www.bing.com/indexnow}
|
||||
INDEXNOW_STRICT: ${INDEXNOW_STRICT:-false}
|
||||
environment:
|
||||
- VITE_GA_MEASUREMENT_ID=${VITE_GA_MEASUREMENT_ID:-}
|
||||
- VITE_PLAUSIBLE_DOMAIN=${VITE_PLAUSIBLE_DOMAIN:-}
|
||||
@@ -152,8 +171,15 @@ services:
|
||||
- VITE_FEATURE_REMOVEBG=${VITE_FEATURE_REMOVEBG:-true}
|
||||
- VITE_SITE_DOMAIN=${VITE_SITE_DOMAIN:-}
|
||||
- VITE_SENTRY_DSN=${VITE_SENTRY_DSN:-}
|
||||
- VITE_CLARITY_PROJECT_ID=${VITE_CLARITY_PROJECT_ID:-}
|
||||
- INDEXNOW_KEY=${INDEXNOW_KEY:-}
|
||||
- INDEXNOW_ENDPOINT=${INDEXNOW_ENDPOINT:-https://www.bing.com/indexnow}
|
||||
- INDEXNOW_STRICT=${INDEXNOW_STRICT:-false}
|
||||
- INDEXNOW_STATE_DIR=/app/.indexnow
|
||||
- INDEXNOW_FULL_SUBMIT=${INDEXNOW_FULL_SUBMIT:-false}
|
||||
volumes:
|
||||
- frontend_build:/app/dist
|
||||
- indexnow_state:/app/.indexnow
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -162,3 +188,4 @@ volumes:
|
||||
output_data:
|
||||
db_data:
|
||||
frontend_build:
|
||||
indexnow_state:
|
||||
|
||||
@@ -3,6 +3,44 @@ FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_GA_MEASUREMENT_ID
|
||||
ARG VITE_PLAUSIBLE_DOMAIN
|
||||
ARG VITE_PLAUSIBLE_SRC
|
||||
ARG VITE_GOOGLE_SITE_VERIFICATION
|
||||
ARG VITE_ADSENSE_CLIENT_ID
|
||||
ARG VITE_ADSENSE_SLOT_HOME_TOP
|
||||
ARG VITE_ADSENSE_SLOT_HOME_BOTTOM
|
||||
ARG VITE_ADSENSE_SLOT_TOP_BANNER
|
||||
ARG VITE_ADSENSE_SLOT_BOTTOM_BANNER
|
||||
ARG VITE_FEATURE_EDITOR
|
||||
ARG VITE_FEATURE_OCR
|
||||
ARG VITE_FEATURE_REMOVEBG
|
||||
ARG VITE_SITE_DOMAIN
|
||||
ARG VITE_SENTRY_DSN
|
||||
ARG VITE_CLARITY_PROJECT_ID
|
||||
ARG INDEXNOW_KEY
|
||||
ARG INDEXNOW_ENDPOINT
|
||||
ARG INDEXNOW_STRICT
|
||||
|
||||
ENV VITE_GA_MEASUREMENT_ID=$VITE_GA_MEASUREMENT_ID \
|
||||
VITE_PLAUSIBLE_DOMAIN=$VITE_PLAUSIBLE_DOMAIN \
|
||||
VITE_PLAUSIBLE_SRC=$VITE_PLAUSIBLE_SRC \
|
||||
VITE_GOOGLE_SITE_VERIFICATION=$VITE_GOOGLE_SITE_VERIFICATION \
|
||||
VITE_ADSENSE_CLIENT_ID=$VITE_ADSENSE_CLIENT_ID \
|
||||
VITE_ADSENSE_SLOT_HOME_TOP=$VITE_ADSENSE_SLOT_HOME_TOP \
|
||||
VITE_ADSENSE_SLOT_HOME_BOTTOM=$VITE_ADSENSE_SLOT_HOME_BOTTOM \
|
||||
VITE_ADSENSE_SLOT_TOP_BANNER=$VITE_ADSENSE_SLOT_TOP_BANNER \
|
||||
VITE_ADSENSE_SLOT_BOTTOM_BANNER=$VITE_ADSENSE_SLOT_BOTTOM_BANNER \
|
||||
VITE_FEATURE_EDITOR=$VITE_FEATURE_EDITOR \
|
||||
VITE_FEATURE_OCR=$VITE_FEATURE_OCR \
|
||||
VITE_FEATURE_REMOVEBG=$VITE_FEATURE_REMOVEBG \
|
||||
VITE_SITE_DOMAIN=$VITE_SITE_DOMAIN \
|
||||
VITE_SENTRY_DSN=$VITE_SENTRY_DSN \
|
||||
VITE_CLARITY_PROJECT_ID=$VITE_CLARITY_PROJECT_ID \
|
||||
INDEXNOW_KEY=$INDEXNOW_KEY \
|
||||
INDEXNOW_ENDPOINT=$INDEXNOW_ENDPOINT \
|
||||
INDEXNOW_STRICT=$INDEXNOW_STRICT
|
||||
|
||||
# Install dependencies
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
428
frontend/package-lock.json
generated
428
frontend/package-lock.json
generated
@@ -2240,29 +2240,6 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
@@ -2299,20 +2276,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/bl": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
|
||||
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"buffer": "^5.5.0",
|
||||
"inherits": "^2.0.4",
|
||||
"readable-stream": "^3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
@@ -2371,33 +2334,6 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer": {
|
||||
"version": "5.7.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
|
||||
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"ieee754": "^1.1.13"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
@@ -2442,23 +2378,6 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvas": {
|
||||
"version": "3.2.1",
|
||||
"resolved": "https://registry.npmjs.org/canvas/-/canvas-3.2.1.tgz",
|
||||
"integrity": "sha512-ej1sPFR5+0YWtaVp6S1N1FVz69TQCqmrkGeRvQxZeAB1nAIcjNTHVwrZtYtWFFBmQsF40/uDLehsW5KuYC99mg==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "^7.0.0",
|
||||
"prebuild-install": "^7.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.12.0 || >= 20.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
@@ -2507,15 +2426,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/chownr": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
|
||||
"integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cli-width": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz",
|
||||
@@ -2775,36 +2685,6 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
"integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mimic-response": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/deep-extend": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
|
||||
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
@@ -2915,18 +2795,6 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/end-of-stream": {
|
||||
"version": "1.4.5",
|
||||
"resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
|
||||
"integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
|
||||
@@ -3107,18 +2975,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
|
||||
"integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
|
||||
"dev": true,
|
||||
"license": "(MIT OR WTFPL)",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
@@ -3546,15 +3402,6 @@
|
||||
"url": "https://github.com/sponsors/rawify"
|
||||
}
|
||||
},
|
||||
"node_modules/fs-constants": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
|
||||
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fs-minipass": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
|
||||
@@ -3705,15 +3552,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/github-from-package": {
|
||||
"version": "0.0.0",
|
||||
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
|
||||
"integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -3919,29 +3757,6 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ieee754": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
|
||||
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/indent-string": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
|
||||
@@ -3971,15 +3786,6 @@
|
||||
"license": "ISC",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ini": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
|
||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/invariant": {
|
||||
"version": "2.2.4",
|
||||
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
|
||||
@@ -4307,21 +4113,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-response": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
|
||||
"integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/min-indent": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
|
||||
@@ -4345,18 +4136,6 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/minipass": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
|
||||
@@ -4414,15 +4193,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/mkdirp-classic": {
|
||||
"version": "0.5.3",
|
||||
"resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
|
||||
"integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
@@ -4523,54 +4293,6 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||
"integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/node-abi": {
|
||||
"version": "3.87.0",
|
||||
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz",
|
||||
"integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"semver": "^7.3.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-abi/node_modules/semver": {
|
||||
"version": "7.7.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
|
||||
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
"integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
@@ -4989,36 +4711,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
"integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
|
||||
"deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.0",
|
||||
"expand-template": "^2.0.3",
|
||||
"github-from-package": "0.0.0",
|
||||
"minimist": "^1.2.3",
|
||||
"mkdirp-classic": "^0.5.3",
|
||||
"napi-build-utils": "^2.0.0",
|
||||
"node-abi": "^3.3.0",
|
||||
"pump": "^3.0.0",
|
||||
"rc": "^1.2.7",
|
||||
"simple-get": "^4.0.0",
|
||||
"tar-fs": "^2.0.0",
|
||||
"tunnel-agent": "^0.6.0"
|
||||
},
|
||||
"bin": {
|
||||
"prebuild-install": "bin.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/pretty-format": {
|
||||
"version": "27.5.1",
|
||||
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
|
||||
@@ -5073,19 +4765,6 @@
|
||||
"url": "https://github.com/sponsors/lupomontero"
|
||||
}
|
||||
},
|
||||
"node_modules/pump": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
|
||||
"integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"end-of-stream": "^1.1.0",
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||
@@ -5124,24 +4803,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rc": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
|
||||
"dev": true,
|
||||
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deep-extend": "^0.6.0",
|
||||
"ini": "~1.3.0",
|
||||
"minimist": "^1.2.0",
|
||||
"strip-json-comments": "~2.0.1"
|
||||
},
|
||||
"bin": {
|
||||
"rc": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||
@@ -5598,34 +5259,6 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/simple-get": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
|
||||
"integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"decompress-response": "^6.0.0",
|
||||
"once": "^1.3.1",
|
||||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sonner": {
|
||||
"version": "1.7.4",
|
||||
"resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz",
|
||||
@@ -5738,18 +5371,6 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
|
||||
@@ -5863,40 +5484,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-fs": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
|
||||
"integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chownr": "^1.1.1",
|
||||
"mkdirp-classic": "^0.5.2",
|
||||
"pump": "^3.0.0",
|
||||
"tar-stream": "^2.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/tar-stream": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
|
||||
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"bl": "^4.0.3",
|
||||
"end-of-stream": "^1.4.1",
|
||||
"fs-constants": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^3.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/tar/node_modules/chownr": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
|
||||
@@ -6084,21 +5671,6 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tunnel-agent": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||
"integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/type-fest": {
|
||||
"version": "5.4.4",
|
||||
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.4.tgz",
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/merge-keywords.mjs && node scripts/generate-seo-assets.mjs",
|
||||
"build": "tsc --noEmit && vite build && node scripts/render-seo-shells.mjs",
|
||||
"indexnow:submit": "node scripts/submit-indexnow.mjs",
|
||||
"indexnow:dry-run": "node scripts/submit-indexnow.mjs --dry-run",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run",
|
||||
|
||||
1
frontend/public/718dc0aa7c7d4d3ebe71e3f97dacef9c.txt
Normal file
1
frontend/public/718dc0aa7c7d4d3ebe71e3f97dacef9c.txt
Normal file
@@ -0,0 +1 @@
|
||||
718dc0aa7c7d4d3ebe71e3f97dacef9c
|
||||
@@ -2,18 +2,18 @@
|
||||
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<sitemap>
|
||||
<loc>https://dociva.io/sitemaps/static.xml</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>https://dociva.io/sitemaps/blog.xml</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>https://dociva.io/sitemaps/tools.xml</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
</sitemap>
|
||||
<sitemap>
|
||||
<loc>https://dociva.io/sitemaps/seo.xml</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
</sitemap>
|
||||
</sitemapindex>
|
||||
|
||||
@@ -2,31 +2,31 @@
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/how-to-compress-pdf-online</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/convert-images-without-losing-quality</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/ocr-extract-text-from-images</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/merge-split-pdf-files</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog/ai-chat-with-pdf-documents</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,55 +2,61 @@
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://dociva.io/</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/about</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.4</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/contact</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.4</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/privacy</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/terms</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>yearly</changefreq>
|
||||
<priority>0.3</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/pricing</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/pricing-transparency</loc>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/blog</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/developers</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<lastmod>2026-04-03</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.5</priority>
|
||||
</url>
|
||||
|
||||
@@ -1,267 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-to-word</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/word-to-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/compress-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/merge-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.9</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/split-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/rotate-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-to-images</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/images-to-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/watermark-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/protect-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/unlock-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/page-numbers</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-editor</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-flowchart</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-to-excel</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/remove-watermark-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/reorder-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/extract-pages</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/image-converter</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/image-resize</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/compress-image</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/ocr</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/remove-background</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/image-to-svg</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/html-to-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/chat-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/summarize-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/translate-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/extract-tables</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/qr-code</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/video-to-gif</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/word-counter</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/text-cleaner</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-to-pptx</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/excel-to-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pptx-to-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/sign-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.8</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/crop-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/flatten-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/repair-pdf</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/pdf-metadata</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/image-crop</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/image-rotate-flip</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://dociva.io/tools/barcode-generator</loc>
|
||||
<lastmod>2026-04-01</lastmod>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
|
||||
</urlset>
|
||||
|
||||
311
frontend/scripts/submit-indexnow.mjs
Normal file
311
frontend/scripts/submit-indexnow.mjs
Normal file
@@ -0,0 +1,311 @@
|
||||
import { access, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const frontendRoot = path.resolve(__dirname, '..');
|
||||
const publicDir = path.join(frontendRoot, 'public');
|
||||
const distDir = path.join(frontendRoot, 'dist');
|
||||
const stateDir = path.resolve(process.env.INDEXNOW_STATE_DIR || path.join(frontendRoot, '.indexnow'));
|
||||
const stateFile = path.join(stateDir, 'last-submission.json');
|
||||
const defaultEndpoint = 'https://www.bing.com/indexnow';
|
||||
const keyPattern = /^[A-Za-z0-9-]{8,128}$/;
|
||||
const dryRun = process.argv.includes('--dry-run') || process.env.INDEXNOW_DRY_RUN === 'true';
|
||||
const forceFullSubmit = process.argv.includes('--full') || process.env.INDEXNOW_FULL_SUBMIT === 'true';
|
||||
const strictMode = process.env.INDEXNOW_STRICT === 'true';
|
||||
const isDirectRun = process.argv[1] ? path.resolve(process.argv[1]) === __filename : false;
|
||||
|
||||
function normalizeOrigin(rawOrigin) {
|
||||
const normalized = String(rawOrigin || 'https://dociva.io').trim().replace(/\/$/, '');
|
||||
return new URL(normalized);
|
||||
}
|
||||
|
||||
function normalizeEndpoint(rawEndpoint) {
|
||||
const endpointUrl = new URL(String(rawEndpoint || defaultEndpoint).trim());
|
||||
|
||||
if (endpointUrl.pathname === '/' || !endpointUrl.pathname) {
|
||||
endpointUrl.pathname = '/indexnow';
|
||||
}
|
||||
|
||||
return endpointUrl.toString();
|
||||
}
|
||||
|
||||
async function pathExists(filePath) {
|
||||
try {
|
||||
await access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeJsonFile(filePath, value) {
|
||||
await mkdir(path.dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
||||
}
|
||||
|
||||
async function ensureKeyFile(key) {
|
||||
const keyFileName = `${key}.txt`;
|
||||
const targets = [publicDir, distDir];
|
||||
|
||||
for (const targetDir of targets) {
|
||||
if (!(await pathExists(targetDir))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await writeFile(path.join(targetDir, keyFileName), key, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveKey() {
|
||||
const envKey = String(process.env.INDEXNOW_KEY || '').trim();
|
||||
if (envKey) {
|
||||
if (!keyPattern.test(envKey)) {
|
||||
throw new Error('INDEXNOW_KEY is not a valid IndexNow key.');
|
||||
}
|
||||
|
||||
await ensureKeyFile(envKey);
|
||||
return envKey;
|
||||
}
|
||||
|
||||
for (const baseDir of [distDir, publicDir]) {
|
||||
if (!(await pathExists(baseDir))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entries = await readdir(baseDir);
|
||||
for (const entry of entries.sort()) {
|
||||
if (!entry.endsWith('.txt')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidateKey = entry.slice(0, -4);
|
||||
if (!keyPattern.test(candidateKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const contents = (await readFile(path.join(baseDir, entry), 'utf8')).trim();
|
||||
if (contents === candidateKey) {
|
||||
return candidateKey;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractLocs(xml) {
|
||||
return [...xml.matchAll(/<loc>([^<]+)<\/loc>/g)].map((match) => match[1].trim());
|
||||
}
|
||||
|
||||
async function collectSitemapFiles() {
|
||||
const sitemapFiles = [];
|
||||
|
||||
for (const baseDir of [distDir, publicDir]) {
|
||||
const nestedSitemapDir = path.join(baseDir, 'sitemaps');
|
||||
if (await pathExists(nestedSitemapDir)) {
|
||||
const entries = await readdir(nestedSitemapDir);
|
||||
for (const entry of entries.sort()) {
|
||||
if (entry.endsWith('.xml')) {
|
||||
sitemapFiles.push(path.join(nestedSitemapDir, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootSitemap = path.join(baseDir, 'sitemap.xml');
|
||||
if (await pathExists(rootSitemap)) {
|
||||
sitemapFiles.push(rootSitemap);
|
||||
}
|
||||
|
||||
if (sitemapFiles.length > 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return sitemapFiles;
|
||||
}
|
||||
|
||||
async function collectUrls(siteOrigin) {
|
||||
const urls = new Set();
|
||||
const sitemapFiles = await collectSitemapFiles();
|
||||
|
||||
for (const sitemapFile of sitemapFiles) {
|
||||
const xml = await readFile(sitemapFile, 'utf8');
|
||||
|
||||
for (const loc of extractLocs(xml)) {
|
||||
let parsedUrl;
|
||||
|
||||
try {
|
||||
parsedUrl = new URL(loc);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedUrl.host !== siteOrigin.host) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsedUrl.pathname.endsWith('.xml')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
urls.add(parsedUrl.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return [...urls].sort();
|
||||
}
|
||||
|
||||
function chunkUrls(urlList, chunkSize) {
|
||||
const chunks = [];
|
||||
|
||||
for (let index = 0; index < urlList.length; index += chunkSize) {
|
||||
chunks.push(urlList.slice(index, index + chunkSize));
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
async function loadPreviousUrls() {
|
||||
if (!(await pathExists(stateFile))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = JSON.parse(await readFile(stateFile, 'utf8'));
|
||||
if (!Array.isArray(payload.urls)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return payload.urls.filter((url) => typeof url === 'string');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function diffUrlLists(currentUrls, previousUrls, submitAll = false) {
|
||||
if (submitAll || previousUrls.length === 0) {
|
||||
return [...new Set(currentUrls)].sort();
|
||||
}
|
||||
|
||||
const currentSet = new Set(currentUrls);
|
||||
const previousSet = new Set(previousUrls);
|
||||
const changedUrls = new Set();
|
||||
|
||||
for (const url of currentSet) {
|
||||
if (!previousSet.has(url)) {
|
||||
changedUrls.add(url);
|
||||
}
|
||||
}
|
||||
|
||||
for (const url of previousSet) {
|
||||
if (!currentSet.has(url)) {
|
||||
changedUrls.add(url);
|
||||
}
|
||||
}
|
||||
|
||||
return [...changedUrls].sort();
|
||||
}
|
||||
|
||||
async function persistSubmittedUrls(currentUrls) {
|
||||
if (dryRun) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await writeJsonFile(stateFile, {
|
||||
updatedAt: new Date().toISOString(),
|
||||
urls: [...new Set(currentUrls)].sort(),
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function submitBatch(endpoint, payload, batchIndex, totalBatches) {
|
||||
if (dryRun) {
|
||||
console.log(`Dry run: batch ${batchIndex}/${totalBatches} -> ${payload.urlList.length} URLs`);
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const responseText = (await response.text()).trim();
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`IndexNow request failed with ${response.status}${responseText ? `: ${responseText}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Submitted IndexNow batch ${batchIndex}/${totalBatches} with ${payload.urlList.length} URLs (${response.status}).`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const siteOrigin = normalizeOrigin(process.env.VITE_SITE_DOMAIN || process.env.SITE_DOMAIN);
|
||||
const endpoint = normalizeEndpoint(process.env.INDEXNOW_ENDPOINT);
|
||||
const key = await resolveKey();
|
||||
|
||||
if (!key) {
|
||||
console.log('Skipping IndexNow submission: no verification key was found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUrls = await collectUrls(siteOrigin);
|
||||
if (currentUrls.length === 0) {
|
||||
console.log('Skipping IndexNow submission: no sitemap URLs were found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const previousUrls = await loadPreviousUrls();
|
||||
const urlList = diffUrlLists(currentUrls, previousUrls, forceFullSubmit);
|
||||
if (urlList.length === 0) {
|
||||
console.log('Skipping IndexNow submission: no changed URLs were detected since the previous successful submission.');
|
||||
return;
|
||||
}
|
||||
|
||||
const keyLocation = `${siteOrigin.origin}/${key}.txt`;
|
||||
const payloads = chunkUrls(urlList, 10000).map((chunk) => ({
|
||||
host: siteOrigin.host,
|
||||
key,
|
||||
keyLocation,
|
||||
urlList: chunk,
|
||||
}));
|
||||
|
||||
console.log(
|
||||
`${dryRun ? 'Preparing' : 'Submitting'} ${urlList.length} URLs to ${endpoint} using ${keyLocation}.`,
|
||||
);
|
||||
|
||||
for (const [index, payload] of payloads.entries()) {
|
||||
await submitBatch(endpoint, payload, index + 1, payloads.length);
|
||||
}
|
||||
|
||||
if (await persistSubmittedUrls(currentUrls)) {
|
||||
console.log(`Saved IndexNow state snapshot to ${stateFile}.`);
|
||||
}
|
||||
}
|
||||
|
||||
if (isDirectRun) {
|
||||
main().catch((error) => {
|
||||
console.error(`IndexNow submission failed: ${error.message}`);
|
||||
if (strictMode) {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export {
|
||||
diffUrlLists,
|
||||
extractLocs,
|
||||
main,
|
||||
normalizeEndpoint,
|
||||
normalizeOrigin,
|
||||
};
|
||||
40
frontend/scripts/submit-indexnow.test.mjs
Normal file
40
frontend/scripts/submit-indexnow.test.mjs
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
diffUrlLists,
|
||||
extractLocs,
|
||||
normalizeEndpoint,
|
||||
normalizeOrigin,
|
||||
} from './submit-indexnow.mjs';
|
||||
|
||||
describe('submit-indexnow helpers', () => {
|
||||
it('normalizes endpoints to the /indexnow path', () => {
|
||||
expect(normalizeEndpoint('https://www.bing.com')).toBe('https://www.bing.com/indexnow');
|
||||
expect(normalizeEndpoint('https://www.bing.com/indexnow')).toBe('https://www.bing.com/indexnow');
|
||||
});
|
||||
|
||||
it('normalizes origins without a trailing slash', () => {
|
||||
expect(normalizeOrigin('https://dociva.io/').toString()).toBe('https://dociva.io/');
|
||||
});
|
||||
|
||||
it('extracts loc entries from sitemap xml', () => {
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset>\n <url><loc>https://dociva.io/a</loc></url>\n <url><loc>https://dociva.io/b</loc></url>\n</urlset>`;
|
||||
expect(extractLocs(xml)).toEqual(['https://dociva.io/a', 'https://dociva.io/b']);
|
||||
});
|
||||
|
||||
it('returns the full set on the first submission', () => {
|
||||
expect(diffUrlLists(['https://dociva.io/a', 'https://dociva.io/b'], [])).toEqual([
|
||||
'https://dociva.io/a',
|
||||
'https://dociva.io/b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns only added and removed urls after the first submission', () => {
|
||||
expect(
|
||||
diffUrlLists(
|
||||
['https://dociva.io/a', 'https://dociva.io/c'],
|
||||
['https://dociva.io/a', 'https://dociva.io/b'],
|
||||
),
|
||||
).toEqual(['https://dociva.io/b', 'https://dociva.io/c']);
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileText, Moon, Sun, Menu, X, ChevronDown, UserRound, Coins } from 'lucide-react';
|
||||
import { FileText, Moon, Sun, Menu, X, ChevronDown, UserRound, Coins, ArrowRight } from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { ensureLanguageResources } from '@/i18n';
|
||||
interface LangOption {
|
||||
@@ -67,49 +67,54 @@ export default function Header() {
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 border-b border-slate-200 bg-white/80 backdrop-blur-lg dark:border-slate-700 dark:bg-slate-900/80">
|
||||
<header className="sticky top-0 z-50 border-b border-slate-200/80 bg-white/85 backdrop-blur-xl dark:border-slate-700/60 dark:bg-slate-900/85">
|
||||
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
{/* Logo */}
|
||||
<Link to="/" className="flex items-center gap-2 text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||
<FileText className="h-7 w-7" />
|
||||
<span>{t('common.appName')}</span>
|
||||
<Link to="/" className="flex items-center gap-2.5 group">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary-600 shadow-sm shadow-primary-200 group-hover:bg-primary-700 transition-colors dark:shadow-primary-900/40">
|
||||
<FileText className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<span className="text-lg font-extrabold tracking-tight text-slate-900 dark:text-white">
|
||||
{t('common.appName')}
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Desktop Navigation */}
|
||||
<nav className="hidden items-center gap-6 md:flex">
|
||||
<nav className="hidden items-center gap-1 md:flex">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm font-medium text-slate-600 transition-colors hover:text-primary-600 dark:text-slate-300 dark:hover:text-primary-400"
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
>
|
||||
{t('common.home')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="text-sm font-medium text-slate-600 transition-colors hover:text-primary-600 dark:text-slate-300 dark:hover:text-primary-400"
|
||||
to="/pricing"
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
>
|
||||
{t('common.about')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/account"
|
||||
className="text-sm font-medium text-slate-600 transition-colors hover:text-primary-600 dark:text-slate-300 dark:hover:text-primary-400"
|
||||
>
|
||||
{t('common.account')}
|
||||
{t('common.pricing')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/developers"
|
||||
className="text-sm font-medium text-slate-600 transition-colors hover:text-primary-600 dark:text-slate-300 dark:hover:text-primary-400"
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
>
|
||||
{t('common.developers')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-100 hover:text-slate-900 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white"
|
||||
>
|
||||
{t('common.about')}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Account / credits pill */}
|
||||
<Link
|
||||
to="/account"
|
||||
className="hidden max-w-[220px] items-center gap-2 rounded-xl border border-slate-200 px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 md:flex dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
className="hidden max-w-[200px] items-center gap-2 rounded-xl border border-slate-200 px-3 py-2 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 md:flex dark:border-slate-700 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
<UserRound className="h-4 w-4" />
|
||||
<UserRound className="h-4 w-4 flex-shrink-0" />
|
||||
<span className="truncate">{user?.email || t('common.account')}</span>
|
||||
{user && credits && (
|
||||
<span className="flex items-center gap-1 rounded-full bg-primary-100 px-2 py-0.5 text-xs font-semibold text-primary-700 dark:bg-primary-900/30 dark:text-primary-300">
|
||||
@@ -119,6 +124,17 @@ export default function Header() {
|
||||
)}
|
||||
</Link>
|
||||
|
||||
{/* CTA — Start Free */}
|
||||
{!user && (
|
||||
<Link
|
||||
to="/account"
|
||||
className="hidden md:inline-flex items-center gap-1.5 rounded-xl bg-primary-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm shadow-primary-200 transition-all hover:bg-primary-700 hover:shadow-md hover:-translate-y-px active:translate-y-0 dark:shadow-primary-900/40"
|
||||
>
|
||||
{t('home.startFree', 'Start Free')}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{/* Dark Mode Toggle */}
|
||||
<button
|
||||
onClick={toggleDark}
|
||||
@@ -190,6 +206,13 @@ export default function Header() {
|
||||
>
|
||||
{t('common.home')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/pricing"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block rounded-lg px-3 py-2.5 text-sm font-medium text-slate-600 transition-colors hover:bg-slate-50 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
>
|
||||
{t('common.pricing')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/about"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
@@ -217,6 +240,16 @@ export default function Header() {
|
||||
>
|
||||
{t('common.developers')}
|
||||
</Link>
|
||||
{!user && (
|
||||
<Link
|
||||
to="/account"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="mt-2 flex items-center justify-center gap-2 rounded-xl bg-primary-600 px-4 py-3 text-sm font-semibold text-white hover:bg-primary-700"
|
||||
>
|
||||
{t('home.startFree', 'Start Free')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
)}
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useCallback } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Upload, Sparkles, PenLine } from 'lucide-react';
|
||||
import { UploadCloud, PenLine, ChevronRight, FileCheck } from 'lucide-react';
|
||||
import ToolSelectorModal from '@/components/shared/ToolSelectorModal';
|
||||
import { useFileStore } from '@/stores/fileStore';
|
||||
import { getToolsForFile, detectFileCategory, getCategoryLabel } from '@/utils/fileRouting';
|
||||
@@ -24,6 +24,15 @@ const ACCEPTED_TYPES = {
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
|
||||
};
|
||||
|
||||
const FORMAT_BADGES = [
|
||||
{ label: 'PDF', color: 'bg-red-50 text-red-700 ring-red-100 dark:bg-red-900/20 dark:text-red-400 dark:ring-red-800/40' },
|
||||
{ label: 'Word', color: 'bg-blue-50 text-blue-700 ring-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:ring-blue-800/40' },
|
||||
{ label: 'JPG', color: 'bg-amber-50 text-amber-700 ring-amber-100 dark:bg-amber-900/20 dark:text-amber-400 dark:ring-amber-800/40' },
|
||||
{ label: 'PNG', color: 'bg-green-50 text-green-700 ring-green-100 dark:bg-green-900/20 dark:text-green-400 dark:ring-green-800/40' },
|
||||
{ label: 'WebP', color: 'bg-violet-50 text-violet-700 ring-violet-100 dark:bg-violet-900/20 dark:text-violet-400 dark:ring-violet-800/40' },
|
||||
{ label: 'MP4', color: 'bg-slate-100 text-slate-600 ring-slate-200 dark:bg-slate-700 dark:text-slate-300 dark:ring-slate-600' },
|
||||
];
|
||||
|
||||
export default function HeroUploadZone() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
@@ -81,37 +90,49 @@ export default function HeroUploadZone() {
|
||||
setMatchedTools([]);
|
||||
}, []);
|
||||
|
||||
const iconGlowClass = isDragActive
|
||||
? 'bg-primary-300/50 scale-125'
|
||||
: 'bg-primary-100/0 group-hover:bg-primary-200/40 group-hover:scale-110';
|
||||
const iconContainerClass = isDragActive
|
||||
? 'bg-primary-100 shadow-primary-200 dark:bg-primary-900/50'
|
||||
: 'bg-primary-50 shadow-sm dark:bg-slate-700/80';
|
||||
const uploadIconClass = isDragActive
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-primary-400 group-hover:text-primary-600 dark:text-primary-500 dark:group-hover:text-primary-400';
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto mt-8 max-w-2xl">
|
||||
<div
|
||||
{...getRootProps()}
|
||||
className={`hero-upload-zone ${isDragActive ? 'drag-active' : ''}`}
|
||||
className={`hero-upload-zone group ${isDragActive ? 'drag-active' : ''}`}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`mb-4 flex h-16 w-16 items-center justify-center rounded-2xl transition-colors ${
|
||||
isDragActive
|
||||
? 'bg-primary-100 dark:bg-primary-900/30'
|
||||
: 'bg-primary-50 dark:bg-primary-900/20'
|
||||
}`}
|
||||
>
|
||||
<Upload
|
||||
className={`h-8 w-8 transition-colors ${
|
||||
isDragActive
|
||||
? 'text-primary-600 dark:text-primary-400'
|
||||
: 'text-primary-500 dark:text-primary-400'
|
||||
}`}
|
||||
/>
|
||||
{/* Cloud icon with animated ring */}
|
||||
<div className="relative mb-6">
|
||||
{/* Outer glow ring */}
|
||||
<div className={`absolute inset-0 rounded-3xl blur-xl transition-all duration-500 ${iconGlowClass}`} />
|
||||
<div className={`relative flex h-20 w-20 items-center justify-center rounded-2xl transition-all duration-300 group-hover:-translate-y-2 group-hover:shadow-lg ${iconContainerClass}`}>
|
||||
<UploadCloud className={`h-10 w-10 transition-colors duration-300 ${uploadIconClass}`} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CTA Text */}
|
||||
<div className="mb-6 flex gap-3 justify-center z-10 relative">
|
||||
{/* Heading */}
|
||||
<h3 className="mb-2 text-xl font-bold text-slate-800 dark:text-slate-100">
|
||||
{isDragActive
|
||||
? t('home.dropFileHere', 'Drop your file here…')
|
||||
: t('home.dragDropTitle', 'Drag & drop your file here')}
|
||||
</h3>
|
||||
<p className="mb-7 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('common.dragDrop', 'or click the button to browse from your device')}
|
||||
</p>
|
||||
|
||||
{/* CTA Buttons */}
|
||||
<div className="relative z-10 mb-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-bold rounded-xl shadow-md transition-colors"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-primary-600 px-7 py-3 text-sm font-semibold text-white shadow-md shadow-primary-200 transition-all duration-200 hover:bg-primary-700 hover:shadow-lg hover:-translate-y-px active:translate-y-0 dark:shadow-primary-900/40"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const input = document.createElement('input');
|
||||
@@ -125,57 +146,64 @@ export default function HeroUploadZone() {
|
||||
input.click();
|
||||
}}
|
||||
>
|
||||
<FileCheck className="h-4 w-4" />
|
||||
{t('home.uploadCta', 'Choose File')}
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.pdf';
|
||||
input.onchange = (ev) => {
|
||||
const fileInput = ev.target as HTMLInputElement;
|
||||
const f = fileInput.files?.[0];
|
||||
if (f) {
|
||||
setStoreFile(f);
|
||||
navigate('/tools/pdf-editor');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
className="px-6 py-3 bg-slate-900 hover:bg-slate-800 text-white font-bold rounded-xl shadow-md transition-colors flex items-center gap-2"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.pdf';
|
||||
input.onchange = (ev) => {
|
||||
const fileInput = ev.target as HTMLInputElement;
|
||||
const f = fileInput.files?.[0];
|
||||
if (f) {
|
||||
setStoreFile(f);
|
||||
navigate('/tools/pdf-editor');
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 bg-white px-6 py-3 text-sm font-semibold text-slate-700 shadow-sm transition-all duration-200 hover:border-slate-300 hover:bg-slate-50 hover:shadow-md hover:-translate-y-px active:translate-y-0 dark:border-slate-600 dark:bg-slate-800 dark:text-slate-200 dark:hover:border-slate-500 dark:hover:bg-slate-700"
|
||||
>
|
||||
<PenLine className="h-5 w-5" />
|
||||
<PenLine className="h-4 w-4" />
|
||||
{t('home.editNow')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mb-3 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('common.dragDrop', 'or drop files here')}
|
||||
</p>
|
||||
{/* Divider */}
|
||||
<div className="mb-5 flex items-center gap-3 w-full max-w-xs">
|
||||
<div className="flex-1 h-px bg-slate-200 dark:bg-slate-700" />
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500 font-medium">
|
||||
{t('home.supportedFormats', 'Supported formats')}
|
||||
</span>
|
||||
<div className="flex-1 h-px bg-slate-200 dark:bg-slate-700" />
|
||||
</div>
|
||||
|
||||
{/* Supported formats */}
|
||||
{/* Coloured format badges */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{['PDF', 'Word', 'JPG', 'PNG', 'WebP', 'MP4'].map((format) => (
|
||||
{FORMAT_BADGES.map(({ label, color }) => (
|
||||
<span
|
||||
key={format}
|
||||
className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600 dark:bg-slate-700 dark:text-slate-300"
|
||||
key={label}
|
||||
className={`rounded-lg px-3 py-1 text-xs font-semibold ring-1 ${color}`}
|
||||
>
|
||||
{format}
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* File size hint */}
|
||||
<p className="mt-3 flex items-center justify-center gap-1.5 text-xs text-slate-400 dark:text-slate-500">
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{/* Size hint */}
|
||||
<p className="mt-4 flex items-center justify-center gap-1.5 text-xs text-slate-400 dark:text-slate-500">
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
{t('home.uploadSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mt-3 rounded-xl bg-red-50 p-3 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
|
||||
<div className="mt-3 rounded-2xl bg-red-50 p-3 ring-1 ring-red-200 dark:bg-red-900/20 dark:ring-red-800">
|
||||
<p className="text-center text-sm text-red-700 dark:text-red-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import type { ReactNode } from 'react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
|
||||
interface ToolCardProps {
|
||||
/** Tool route path */
|
||||
@@ -23,18 +24,28 @@ export default function ToolCard({
|
||||
}: ToolCardProps) {
|
||||
return (
|
||||
<Link to={to} className="group block h-full">
|
||||
<div className="flex h-full flex-col gap-3 rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200 transition-all duration-200 hover:-translate-y-1 hover:shadow-md hover:ring-primary-300 dark:bg-slate-800 dark:ring-slate-700 dark:hover:ring-primary-500">
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-xl transition-colors ${bgColor} dark:bg-slate-700 dark:group-hover:bg-slate-600`}
|
||||
>
|
||||
{icon}
|
||||
<div className="relative flex h-full flex-col gap-3 overflow-hidden rounded-2xl bg-white p-5 shadow-sm ring-1 ring-slate-200/80 transition-all duration-200 hover:-translate-y-1 hover:shadow-lg hover:shadow-slate-200/60 hover:ring-primary-200 dark:bg-slate-800/80 dark:ring-slate-700 dark:hover:ring-primary-700/60 dark:hover:shadow-slate-900/60">
|
||||
{/* Top color accent bar — slides in on hover */}
|
||||
<div className="absolute inset-x-0 top-0 h-[3px] origin-left scale-x-0 rounded-t-2xl bg-gradient-to-r from-primary-500 to-accent-500 transition-transform duration-300 group-hover:scale-x-100" />
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
{/* Icon + title */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-11 w-11 flex-shrink-0 items-center justify-center rounded-xl ring-1 ring-black/5 transition-transform duration-200 group-hover:scale-110 ${bgColor} dark:ring-white/5 dark:brightness-90`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<h3 className="text-sm font-bold leading-snug text-slate-800 transition-colors group-hover:text-primary-700 dark:text-slate-100 dark:group-hover:text-primary-400">
|
||||
{title}
|
||||
</h3>
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-slate-900 transition-colors group-hover:text-primary-600 dark:text-slate-100 dark:group-hover:text-primary-400">
|
||||
{title}
|
||||
</h3>
|
||||
|
||||
{/* Arrow indicator */}
|
||||
<ArrowRight className="mt-0.5 h-4 w-4 flex-shrink-0 text-slate-300 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-primary-500 dark:text-slate-600 dark:group-hover:text-primary-400" />
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 line-clamp-2 dark:text-slate-400 mt-1">
|
||||
|
||||
<p className="text-xs leading-relaxed text-slate-500 line-clamp-2 dark:text-slate-400">
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useDeferredValue } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Link } from 'react-router-dom';
|
||||
import SEOHead from '@/components/seo/SEOHead';
|
||||
import { generateOrganization, generateWebSite, getSiteOrigin } from '@/utils/seo';
|
||||
import {
|
||||
@@ -38,6 +39,15 @@ import {
|
||||
Wrench,
|
||||
Presentation,
|
||||
Barcode,
|
||||
ShieldCheck,
|
||||
Zap,
|
||||
Globe,
|
||||
UploadCloud,
|
||||
MousePointerClick,
|
||||
Download,
|
||||
ArrowRight,
|
||||
Star,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import ToolCard from '@/components/shared/ToolCard';
|
||||
import HeroUploadZone from '@/components/shared/HeroUploadZone';
|
||||
@@ -78,6 +88,39 @@ function manifestToToolInfo(tools: readonly ToolEntry[]): ToolInfo[] {
|
||||
const pdfTools: ToolInfo[] = manifestToToolInfo(getHomepageTools('pdf'));
|
||||
const otherTools: ToolInfo[] = manifestToToolInfo(getHomepageTools('other'));
|
||||
|
||||
const HOW_IT_WORKS = [
|
||||
{
|
||||
step: '01',
|
||||
icon: UploadCloud,
|
||||
titleKey: 'home.howStep1Title',
|
||||
titleDefault: 'Upload your file',
|
||||
descKey: 'home.howStep1Desc',
|
||||
descDefault: 'Drag & drop or click to select. PDF, Word, images and more — up to 200 MB.',
|
||||
color: 'bg-blue-600',
|
||||
glow: 'shadow-blue-200 dark:shadow-blue-900/40',
|
||||
},
|
||||
{
|
||||
step: '02',
|
||||
icon: MousePointerClick,
|
||||
titleKey: 'home.howStep2Title',
|
||||
titleDefault: 'Choose a tool',
|
||||
descKey: 'home.howStep2Desc',
|
||||
descDefault: 'We detect your file type and suggest the best tools automatically.',
|
||||
color: 'bg-violet-600',
|
||||
glow: 'shadow-violet-200 dark:shadow-violet-900/40',
|
||||
},
|
||||
{
|
||||
step: '03',
|
||||
icon: Download,
|
||||
titleKey: 'home.howStep3Title',
|
||||
titleDefault: 'Download instantly',
|
||||
descKey: 'home.howStep3Desc',
|
||||
descDefault: 'Your file is ready in seconds. No account needed — files are auto-deleted.',
|
||||
color: 'bg-emerald-600',
|
||||
glow: 'shadow-emerald-200 dark:shadow-emerald-900/40',
|
||||
},
|
||||
];
|
||||
|
||||
export default function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const siteOrigin = getSiteOrigin(typeof window !== 'undefined' ? window.location.origin : '');
|
||||
@@ -122,33 +165,103 @@ export default function HomePage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="py-12 sm:py-20 bg-gradient-to-b from-slate-50 to-white dark:from-slate-900 dark:to-slate-950 px-4 mb-10 rounded-b-[3rem]">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-slate-900 sm:text-6xl dark:text-white mb-6">
|
||||
{/* ── Hero Section ──────────────────────────────────────────── */}
|
||||
<section className="hero-gradient-bg relative overflow-hidden py-16 sm:py-24 px-4 mb-10 rounded-b-[3rem]">
|
||||
{/* Decorative blobs */}
|
||||
<div className="pointer-events-none absolute -top-32 left-1/2 h-[600px] w-[600px] -translate-x-1/2 rounded-full bg-primary-400/10 blur-3xl dark:bg-primary-600/10" />
|
||||
<div className="pointer-events-none absolute top-0 right-0 h-80 w-80 rounded-full bg-accent-400/8 blur-3xl dark:bg-accent-600/8" />
|
||||
|
||||
<div className="relative max-w-4xl mx-auto text-center">
|
||||
{/* Animated badge */}
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-primary-200 bg-primary-50 px-4 py-1.5 mb-6 dark:border-primary-800 dark:bg-primary-900/30">
|
||||
<span className="h-2 w-2 rounded-full bg-primary-500 animate-pulse" />
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-primary-700 dark:text-primary-300">
|
||||
{t('home.heroBadge', 'Free Online PDF & File Tools')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-slate-900 sm:text-6xl lg:text-7xl dark:text-white mb-6 leading-[1.1]">
|
||||
{t('home.hero')}
|
||||
</h1>
|
||||
<p className="mx-auto max-w-2xl text-lg text-slate-600 dark:text-slate-400 mb-10 leading-relaxed">
|
||||
<p className="mx-auto max-w-2xl text-lg text-slate-500 dark:text-slate-400 mb-4 leading-relaxed">
|
||||
{t('home.heroSub')}
|
||||
</p>
|
||||
|
||||
{/* Trust strip */}
|
||||
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2 mb-10">
|
||||
{[
|
||||
{ icon: ShieldCheck, text: t('home.trustNoSignup', 'No sign-up needed') },
|
||||
{ icon: Zap, text: t('home.trustFast', 'Results in seconds') },
|
||||
{ icon: Lock, text: t('home.trustSecure', 'Files auto-deleted') },
|
||||
{ icon: Globe, text: t('home.trust30Tools', '30+ free tools') },
|
||||
].map(({ icon: Icon, text }) => (
|
||||
<div key={text} className="flex items-center gap-1.5 text-sm text-slate-500 dark:text-slate-400">
|
||||
<Icon className="h-4 w-4 text-primary-500 flex-shrink-0" />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Smart Upload Zone */}
|
||||
<HeroUploadZone />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ad Slot */}
|
||||
{/* ── Ad Slot ───────────────────────────────────────────────── */}
|
||||
<AdSlot slot="home-top" format="horizontal" className="mb-8" />
|
||||
|
||||
{/* ── Social Proof Strip ────────────────────────────────────── */}
|
||||
<SocialProofStrip className="mb-10" />
|
||||
|
||||
<section className="mb-10 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
{/* ── How It Works ──────────────────────────────────────────── */}
|
||||
<section className="mb-14 px-2">
|
||||
<div className="mb-10 text-center">
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
|
||||
{t('home.howItWorksLabel', 'Simple process')}
|
||||
</p>
|
||||
<h2 className="text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white">
|
||||
{t('home.howItWorksTitle', 'Convert & edit in 3 steps')}
|
||||
</h2>
|
||||
<p className="mt-3 text-slate-500 dark:text-slate-400 max-w-xl mx-auto">
|
||||
{t('home.howItWorksSubtitle', 'No account, no installation, no waiting. Just upload, choose a tool, and download.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative grid gap-6 sm:grid-cols-3">
|
||||
{HOW_IT_WORKS.map(({ step, icon: Icon, titleKey, titleDefault, descKey, descDefault, color, glow }, idx) => (
|
||||
<div key={step} className="relative">
|
||||
{/* Connector line (between steps, hidden on mobile) */}
|
||||
{idx < HOW_IT_WORKS.length - 1 && (
|
||||
<div className="step-connector" />
|
||||
)}
|
||||
<div className="flex flex-col items-center text-center rounded-2xl bg-white p-7 shadow-sm ring-1 ring-slate-200/80 dark:bg-slate-800/70 dark:ring-slate-700/60">
|
||||
{/* Numbered icon */}
|
||||
<div className={`relative mb-5 flex h-16 w-16 items-center justify-center rounded-2xl ${color} shadow-lg ${glow} text-white`}>
|
||||
<Icon className="h-8 w-8" />
|
||||
<span className="absolute -right-2 -top-2 flex h-6 w-6 items-center justify-center rounded-full bg-white text-xs font-black text-slate-700 shadow-sm ring-1 ring-slate-200 dark:bg-slate-700 dark:text-slate-200 dark:ring-slate-600">
|
||||
{parseInt(step, 10)}
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="mb-2 text-base font-bold text-slate-900 dark:text-white">
|
||||
{t(titleKey, titleDefault)}
|
||||
</h3>
|
||||
<p className="text-sm leading-relaxed text-slate-500 dark:text-slate-400">
|
||||
{t(descKey, descDefault)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Search & Tools ────────────────────────────────────────── */}
|
||||
<section className="mb-8 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">
|
||||
{t('common.search')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
|
||||
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
||||
{t('home.searchToolsPlaceholder')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -176,41 +289,17 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mb-12 rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
|
||||
{t('common.developers')}
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{t('pages.developers.ctaTitle')}
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-600 dark:text-slate-400">
|
||||
{t('pages.developers.ctaSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<a
|
||||
href="/developers"
|
||||
className="inline-flex items-center justify-center rounded-xl bg-primary-600 px-5 py-3 text-sm font-semibold text-white transition-colors hover:bg-primary-700"
|
||||
>
|
||||
{t('pages.developers.openDocs')}
|
||||
</a>
|
||||
<a
|
||||
href="/account"
|
||||
className="inline-flex items-center justify-center rounded-xl border border-slate-200 px-5 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
{t('pages.developers.getApiKey')}
|
||||
</a>
|
||||
</div>
|
||||
{/* ── PDF Tools Grid ────────────────────────────────────────── */}
|
||||
<section className="mb-12">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h2 className="text-xl font-bold text-slate-800 dark:text-slate-200">
|
||||
{t('home.pdfTools')}
|
||||
</h2>
|
||||
<Link to="/tools" className="flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300">
|
||||
{t('common.allTools')}
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tools Grid */}
|
||||
<section>
|
||||
<h2 className="mb-6 text-center text-xl font-semibold text-slate-800 dark:text-slate-200">
|
||||
{t('home.pdfTools')}
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mb-10">
|
||||
{filteredPdfTools.map((tool) => (
|
||||
<ToolCard
|
||||
@@ -224,7 +313,7 @@ export default function HomePage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<h2 className="mb-6 text-center text-xl font-semibold text-slate-800 dark:text-slate-200">
|
||||
<h2 className="mb-6 text-xl font-bold text-slate-800 dark:text-slate-200">
|
||||
{t('home.otherTools', 'Other Tools')}
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 mb-12">
|
||||
@@ -249,49 +338,149 @@ export default function HomePage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Features / Why Choose Us */}
|
||||
<section className="py-16 bg-slate-50 dark:bg-slate-900 rounded-3xl mb-12 px-6 sm:px-12 text-center">
|
||||
<h2 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-white mb-10">
|
||||
{t('home.featuresTitle', 'A smarter way to convert and edit online')}
|
||||
</h2>
|
||||
<div className="grid gap-8 sm:grid-cols-3 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400 mb-6">
|
||||
<Layers className="h-8 w-8" />
|
||||
{/* ── Features / Why Choose Us ──────────────────────────────── */}
|
||||
<section className="mb-14 overflow-hidden rounded-3xl bg-slate-50 px-6 py-16 dark:bg-slate-900 sm:px-12">
|
||||
<div className="mb-12 text-center">
|
||||
<p className="mb-2 text-xs font-bold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
|
||||
{t('home.whyChooseLabel', 'Why Dociva')}
|
||||
</p>
|
||||
<h2 className="text-3xl font-extrabold tracking-tight text-slate-900 dark:text-white">
|
||||
{t('home.featuresTitle', 'A smarter way to work with files')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-8 sm:grid-cols-3">
|
||||
{[
|
||||
{
|
||||
icon: Layers,
|
||||
bg: 'bg-blue-100 dark:bg-blue-900/30',
|
||||
color: 'text-blue-600 dark:text-blue-400',
|
||||
titleKey: 'home.feature1Title',
|
||||
titleDefault: 'One complete workspace',
|
||||
descKey: 'home.feature1Desc',
|
||||
descDefault: 'Edit, convert, compress, merge, split — without switching tabs.',
|
||||
perks: [
|
||||
t('home.feature1Perk1', '30+ tools in one place'),
|
||||
t('home.feature1Perk2', 'PDF, image & video support'),
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: CheckCircle2,
|
||||
bg: 'bg-emerald-100 dark:bg-emerald-900/30',
|
||||
color: 'text-emerald-600 dark:text-emerald-400',
|
||||
titleKey: 'home.feature2Title',
|
||||
titleDefault: 'Accuracy you can trust',
|
||||
descKey: 'home.feature2Desc',
|
||||
descDefault: 'Pixel-perfect, editable output in seconds with zero quality loss.',
|
||||
perks: [
|
||||
t('home.feature2Perk1', 'Preserve fonts & layouts'),
|
||||
t('home.feature2Perk2', 'Batch-tested quality'),
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
bg: 'bg-violet-100 dark:bg-violet-900/30',
|
||||
color: 'text-violet-600 dark:text-violet-400',
|
||||
titleKey: 'home.feature3Title',
|
||||
titleDefault: 'Built-in security',
|
||||
descKey: 'home.feature3Desc',
|
||||
descDefault: 'Files are automatically deleted after processing. No account required.',
|
||||
perks: [
|
||||
t('home.feature3Perk1', 'Auto-deletion after 1 hour'),
|
||||
t('home.feature3Perk2', 'Encrypted transfers'),
|
||||
],
|
||||
},
|
||||
].map(({ icon: Icon, bg, color, titleKey, titleDefault, descKey, descDefault, perks }) => (
|
||||
<div key={titleKey} className="flex flex-col rounded-2xl bg-white p-7 shadow-sm ring-1 ring-slate-200/80 dark:bg-slate-800 dark:ring-slate-700">
|
||||
<div className={`mb-5 flex h-14 w-14 items-center justify-center rounded-2xl ${bg}`}>
|
||||
<Icon className={`h-7 w-7 ${color}`} />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||
{t(titleKey, titleDefault)}
|
||||
</h3>
|
||||
<p className="mb-5 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
|
||||
{t(descKey, descDefault)}
|
||||
</p>
|
||||
<ul className="mt-auto space-y-2">
|
||||
{perks.map((perk) => (
|
||||
<li key={perk} className="flex items-center gap-2 text-xs font-medium text-slate-600 dark:text-slate-300">
|
||||
<Star className="h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
|
||||
{perk}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
||||
{t('home.feature1Title', 'One complete workspace')}
|
||||
</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
{t('home.feature1Desc', 'Edit, convert, compress, merge, split without switching tabs.')}
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Developer API Banner ──────────────────────────────────── */}
|
||||
<section className="mb-10 rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-700 dark:bg-slate-900/70">
|
||||
<div className="flex flex-col gap-6 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-primary-600 dark:text-primary-400">
|
||||
{t('common.developers')}
|
||||
</p>
|
||||
<h2 className="mt-2 text-2xl font-bold text-slate-900 dark:text-white">
|
||||
{t('pages.developers.ctaTitle')}
|
||||
</h2>
|
||||
<p className="mt-2 text-slate-500 dark:text-slate-400">
|
||||
{t('pages.developers.ctaSubtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-emerald-100 text-emerald-600 dark:bg-emerald-900/30 dark:text-emerald-400 mb-6">
|
||||
<span className="text-2xl font-bold inline-block">100%</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
||||
{t('home.feature2Title', 'Accuracy you can trust')}
|
||||
</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
{t('home.feature2Desc', 'Get pixel-perfect, editable files in seconds with zero quality loss.')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-red-100 text-red-600 dark:bg-red-900/30 dark:text-red-400 mb-6">
|
||||
<Lock className="h-8 w-8" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
||||
{t('home.feature3Title', 'Built-in security')}
|
||||
</h3>
|
||||
<p className="text-slate-500 dark:text-slate-400">
|
||||
{t('home.feature3Desc', 'Access files securely, protected by automatic encryption.')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
to="/developers"
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-primary-600 px-5 py-3 text-sm font-semibold text-white shadow-sm transition-all hover:bg-primary-700 hover:-translate-y-px"
|
||||
>
|
||||
{t('pages.developers.openDocs')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/account"
|
||||
className="inline-flex items-center justify-center rounded-xl border border-slate-200 px-5 py-3 text-sm font-semibold text-slate-700 transition-colors hover:bg-slate-50 dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800"
|
||||
>
|
||||
{t('pages.developers.getApiKey')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Ad Slot - Bottom */}
|
||||
{/* ── Bottom CTA Banner ─────────────────────────────────────── */}
|
||||
<section className="relative mb-14 overflow-hidden rounded-[2rem] bg-gradient-to-br from-primary-600 via-primary-700 to-accent-700 px-8 py-16 text-center">
|
||||
{/* Decorative blobs */}
|
||||
<div className="pointer-events-none absolute -right-16 -top-16 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
|
||||
<div className="pointer-events-none absolute -bottom-16 -left-16 h-64 w-64 rounded-full bg-white/10 blur-3xl" />
|
||||
|
||||
<div className="relative">
|
||||
<p className="mb-2 text-sm font-bold uppercase tracking-widest text-primary-200">
|
||||
{t('home.ctaBannerLabel', 'Get started today')}
|
||||
</p>
|
||||
<h2 className="mb-4 text-3xl font-extrabold text-white sm:text-4xl">
|
||||
{t('home.ctaBannerTitle', 'Ready to convert your files?')}
|
||||
</h2>
|
||||
<p className="mx-auto mb-10 max-w-xl text-lg text-primary-100">
|
||||
{t('home.ctaBannerSubtitle', 'Join thousands of users who convert, compress, and edit their files every day — completely free.')}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/tools"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-white px-8 py-3.5 text-sm font-bold text-primary-700 shadow-lg transition-all hover:shadow-xl hover:-translate-y-0.5 active:translate-y-0"
|
||||
>
|
||||
{t('home.ctaBrowseTools', 'Browse All Tools')}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
to="/account"
|
||||
className="inline-flex items-center gap-2 rounded-xl border-2 border-white/30 bg-white/10 px-8 py-3.5 text-sm font-bold text-white backdrop-blur transition-all hover:bg-white/20 hover:-translate-y-0.5"
|
||||
>
|
||||
{t('home.ctaCreateAccount', 'Create Free Account')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Ad Slot - Bottom ──────────────────────────────────────── */}
|
||||
<AdSlot slot="home-bottom" className="mt-12" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -114,17 +114,84 @@
|
||||
animation: fadeSlideIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Hero upload zone — larger variant for the homepage */
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Hero Upload Zone — premium glassmorphism card for the homepage
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
.hero-upload-zone {
|
||||
@apply flex flex-col items-center justify-center rounded-2xl border-2 border-dashed border-slate-300 bg-gradient-to-b from-slate-50 to-white p-10 text-center transition-all duration-200 cursor-pointer sm:p-12 dark:border-slate-600 dark:from-slate-800/60 dark:to-slate-800/30;
|
||||
@apply relative flex flex-col items-center justify-center rounded-3xl border border-slate-200/80 bg-white/80 backdrop-blur-sm p-10 text-center transition-all duration-300 ease-in-out cursor-pointer sm:p-14 shadow-sm dark:border-slate-700/60 dark:bg-slate-800/60 dark:backdrop-blur-sm;
|
||||
background-image: radial-gradient(ellipse at top, rgba(219, 234, 254, 0.3) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
.dark .hero-upload-zone {
|
||||
background-image: radial-gradient(ellipse at top, rgba(30, 58, 138, 0.15) 0%, transparent 70%);
|
||||
}
|
||||
|
||||
.hero-upload-zone::before {
|
||||
content: '';
|
||||
@apply absolute inset-0 rounded-3xl transition-opacity duration-300 opacity-0;
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.06) 0%, rgba(168, 85, 247, 0.04) 100%);
|
||||
}
|
||||
|
||||
.hero-upload-zone:hover::before {
|
||||
@apply opacity-100;
|
||||
}
|
||||
|
||||
.hero-upload-zone:hover {
|
||||
@apply border-primary-400 bg-gradient-to-b from-primary-50 to-white shadow-lg dark:border-primary-500 dark:from-primary-900/20 dark:to-slate-800/30;
|
||||
@apply border-primary-300 shadow-lg shadow-primary-100/50 -translate-y-1 dark:border-primary-600/60 dark:shadow-primary-900/30;
|
||||
}
|
||||
|
||||
.hero-upload-zone.drag-active {
|
||||
@apply border-primary-500 bg-gradient-to-b from-primary-100 to-primary-50 ring-2 ring-primary-300 shadow-xl dark:border-primary-400 dark:from-primary-900/30 dark:to-primary-900/10 dark:ring-primary-600;
|
||||
@apply border-primary-500 shadow-2xl shadow-primary-200/60 scale-[1.02] dark:border-primary-400 dark:shadow-primary-900/40;
|
||||
background-image: radial-gradient(ellipse at top, rgba(191, 219, 254, 0.5) 0%, rgba(219, 234, 254, 0.2) 100%);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Glassmorphism card utility
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
.glass-card {
|
||||
@apply bg-white/70 backdrop-blur-md border border-white/50 shadow-sm dark:bg-slate-800/60 dark:border-slate-700/50;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Gradient hero mesh background
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
.hero-gradient-bg {
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% -20%, rgba(59, 130, 246, 0.12) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 60% 40% at 80% 20%, rgba(168, 85, 247, 0.06) 0%, transparent 60%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #ffffff 100%);
|
||||
}
|
||||
|
||||
.dark .hero-gradient-bg {
|
||||
background:
|
||||
radial-gradient(ellipse 80% 60% at 50% -20%, rgba(30, 58, 138, 0.3) 0%, transparent 70%),
|
||||
radial-gradient(ellipse 60% 40% at 80% 20%, rgba(88, 28, 135, 0.15) 0%, transparent 60%),
|
||||
linear-gradient(180deg, #0f172a 0%, #0f172a 100%);
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
Shimmer loading effect
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
@keyframes shimmer-sweep {
|
||||
0% { background-position: -200% center; }
|
||||
100% { background-position: 200% center; }
|
||||
}
|
||||
|
||||
.shimmer-text {
|
||||
background: linear-gradient(90deg, #1e40af 30%, #7c3aed 50%, #1e40af 70%);
|
||||
background-size: 200% auto;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
animation: shimmer-sweep 4s linear infinite;
|
||||
}
|
||||
|
||||
/* ──────────────────────────────────────────────────────────────────────────
|
||||
How it Works — connector line between steps
|
||||
────────────────────────────────────────────────────────────────────────── */
|
||||
.step-connector {
|
||||
@apply absolute top-8 left-[calc(50%+2.5rem)] hidden h-px w-[calc(100%-5rem)] sm:block;
|
||||
background: linear-gradient(90deg, rgba(59, 130, 246, 0.4) 0%, rgba(59, 130, 246, 0.1) 100%);
|
||||
}
|
||||
|
||||
/* Modal animations */
|
||||
|
||||
@@ -38,6 +38,51 @@ export default {
|
||||
sans: ['Inter', 'Tajawal', 'system-ui', 'sans-serif'],
|
||||
arabic: ['Tajawal', 'Inter', 'sans-serif'],
|
||||
},
|
||||
borderRadius: {
|
||||
'3xl': '1.5rem',
|
||||
'4xl': '2rem',
|
||||
},
|
||||
boxShadow: {
|
||||
'glow': '0 0 20px -4px rgba(59, 130, 246, 0.4)',
|
||||
'glow-lg': '0 0 40px -8px rgba(59, 130, 246, 0.5)',
|
||||
},
|
||||
transitionTimingFunction: {
|
||||
'smooth': 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
animation: {
|
||||
'float': 'float 3s ease-in-out infinite',
|
||||
'shimmer': 'shimmer 2.5s linear infinite',
|
||||
'fade-up': 'fadeUp 0.5s ease-out forwards',
|
||||
'fade-in': 'fadeIn 0.4s ease-out forwards',
|
||||
'scale-in': 'scaleIn 0.3s ease-out forwards',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
float: {
|
||||
'0%, 100%': { transform: 'translateY(0px)' },
|
||||
'50%': { transform: 'translateY(-10px)' },
|
||||
},
|
||||
shimmer: {
|
||||
'0%': { backgroundPosition: '-200% center' },
|
||||
'100%': { backgroundPosition: '200% center' },
|
||||
},
|
||||
fadeUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(24px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
scaleIn: {
|
||||
'0%': { opacity: '0', transform: 'scale(0.92)' },
|
||||
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||
},
|
||||
},
|
||||
backgroundImage: {
|
||||
'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))',
|
||||
'gradient-conic': 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
|
||||
@@ -28,20 +28,44 @@ if [ ! -f ".env" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}1/7 — Pulling latest code...${NC}"
|
||||
read_env_value() {
|
||||
local key="$1"
|
||||
local fallback="${2:-}"
|
||||
local shell_value="${!key-}"
|
||||
local file_value
|
||||
|
||||
file_value="$(grep -E "^${key}=" .env 2>/dev/null | tail -n 1 | cut -d= -f2- || true)"
|
||||
|
||||
if [ -n "$shell_value" ]; then
|
||||
printf '%s' "$shell_value"
|
||||
elif [ -n "$file_value" ]; then
|
||||
printf '%s' "$file_value"
|
||||
else
|
||||
printf '%s' "$fallback"
|
||||
fi
|
||||
}
|
||||
|
||||
normalize_bool() {
|
||||
printf '%s' "$1" | tr '[:upper:]' '[:lower:]'
|
||||
}
|
||||
|
||||
INDEXNOW_AUTO_SUBMIT_VALUE="$(normalize_bool "$(read_env_value INDEXNOW_AUTO_SUBMIT 1)")"
|
||||
INDEXNOW_STRICT_VALUE="$(normalize_bool "$(read_env_value INDEXNOW_STRICT false)")"
|
||||
|
||||
echo -e "${YELLOW}1/8 — Pulling latest code...${NC}"
|
||||
git pull origin main 2>/dev/null || echo "Not a git repo or no remote, skipping pull."
|
||||
|
||||
echo -e "${YELLOW}2/7 — Building Docker images...${NC}"
|
||||
echo -e "${YELLOW}2/8 — Building Docker images...${NC}"
|
||||
docker compose -f docker-compose.prod.yml build --no-cache
|
||||
|
||||
echo -e "${YELLOW}3/7 — Stopping old containers...${NC}"
|
||||
echo -e "${YELLOW}3/8 — Stopping old containers...${NC}"
|
||||
docker compose -f docker-compose.prod.yml down --remove-orphans
|
||||
|
||||
echo -e "${YELLOW}4/7 — Starting services...${NC}"
|
||||
echo -e "${YELLOW}4/8 — Starting services...${NC}"
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
if [ "${SKIP_AI_RUNTIME_CHECKS:-0}" != "1" ]; then
|
||||
echo -e "${YELLOW}5/7 — Verifying AI runtime in backend + worker...${NC}"
|
||||
echo -e "${YELLOW}5/8 — Verifying AI runtime in backend + worker...${NC}"
|
||||
for service in backend celery_worker; do
|
||||
if ! docker compose -f docker-compose.prod.yml exec -T "$service" python - <<'PY'
|
||||
import importlib.util
|
||||
@@ -69,10 +93,10 @@ PY
|
||||
fi
|
||||
done
|
||||
else
|
||||
echo -e "${YELLOW}5/7 — Skipping AI runtime checks (SKIP_AI_RUNTIME_CHECKS=1).${NC}"
|
||||
echo -e "${YELLOW}5/8 — Skipping AI runtime checks (SKIP_AI_RUNTIME_CHECKS=1).${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}6/7 — Waiting for health check...${NC}"
|
||||
echo -e "${YELLOW}6/8 — Waiting for health check...${NC}"
|
||||
sleep 10
|
||||
|
||||
# Health check
|
||||
@@ -84,7 +108,23 @@ else
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}7/7 — Current containers:${NC}"
|
||||
if [ "${INDEXNOW_AUTO_SUBMIT_VALUE:-1}" = "1" ] || [ "${INDEXNOW_AUTO_SUBMIT_VALUE:-true}" = "true" ]; then
|
||||
echo -e "${YELLOW}7/8 — Submitting URLs to IndexNow...${NC}"
|
||||
if docker compose -f docker-compose.prod.yml run --rm frontend_build_step node scripts/submit-indexnow.mjs; then
|
||||
echo -e "${GREEN}✓ IndexNow notification completed.${NC}"
|
||||
else
|
||||
if [ "$INDEXNOW_STRICT_VALUE" = "1" ] || [ "$INDEXNOW_STRICT_VALUE" = "true" ]; then
|
||||
echo -e "${RED}✗ IndexNow notification failed and INDEXNOW_STRICT is enabled.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}! IndexNow notification failed; deployment will continue.${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${YELLOW}7/8 — Skipping IndexNow notification (INDEXNOW_AUTO_SUBMIT=0).${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${YELLOW}8/8 — Current containers:${NC}"
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user