@omnizap-system/omnizap 2.5.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.clusterfuzzlite/Dockerfile +10 -0
- package/.env.example +907 -0
- package/.github/codeql/codeql-config.yml +10 -0
- package/.github/dependabot.yml +35 -0
- package/.github/workflows/ci.yml +73 -0
- package/.github/workflows/codeql.yml +106 -0
- package/.github/workflows/db-migration-check.yml +98 -0
- package/.github/workflows/dependency-review.yml +22 -0
- package/.github/workflows/deploy.yml +95 -0
- package/.github/workflows/release.yml +106 -0
- package/.github/workflows/security-attest-provenance.yml +51 -0
- package/.github/workflows/security-gitleaks.yml +34 -0
- package/.github/workflows/security-runner-hardening.yml +31 -0
- package/.github/workflows/security-scorecard.yml +44 -0
- package/.github/workflows/security-zap-baseline.yml +44 -0
- package/.github/workflows/security-zap-full-scan.yml +43 -0
- package/.github/workflows/security-zizmor.yml +36 -0
- package/.github/workflows/wiki-sync.yml +44 -0
- package/.gitleaks.toml +15 -0
- package/.prettierrc +34 -0
- package/CODE_OF_CONDUCT.md +114 -0
- package/LICENSE +56 -0
- package/README.md +110 -0
- package/SECURITY.md +110 -0
- package/app/config/index.js +4 -0
- package/app/configParts/adminIdentity.js +92 -0
- package/app/configParts/baileysConfig.js +1818 -0
- package/app/configParts/groupUtils.js +692 -0
- package/app/configParts/loggerConfig.js +394 -0
- package/app/configParts/messagePersistenceService.js +305 -0
- package/app/connection/baileysCompatibility.test.js +40 -0
- package/app/connection/baileysDbAuthState.js +344 -0
- package/app/connection/socketController.js +2243 -0
- package/app/controllers/messageController.js +7 -0
- package/app/controllers/messagePipeline/commandMiddleware.js +146 -0
- package/app/controllers/messagePipeline/conversationMiddleware.js +183 -0
- package/app/controllers/messagePipeline/messagePipelineMiddlewares.test.js +522 -0
- package/app/controllers/messagePipeline/postProcessingMiddleware.js +41 -0
- package/app/controllers/messagePipeline/preProcessingMiddlewares.js +166 -0
- package/app/controllers/messageProcessingPipeline.js +699 -0
- package/app/modules/adminModule/AGENT.md +4056 -0
- package/app/modules/adminModule/adminAiHelpService.js +56 -0
- package/app/modules/adminModule/adminConfigRuntime.js +177 -0
- package/app/modules/adminModule/commandConfig.json +7122 -0
- package/app/modules/adminModule/groupCommandHandlers.js +1823 -0
- package/app/modules/adminModule/groupCommandHandlers.test.js +350 -0
- package/app/modules/adminModule/groupEventHandlers.js +399 -0
- package/app/modules/aiModule/AGENT.md +547 -0
- package/app/modules/aiModule/aiAiHelpService.js +14 -0
- package/app/modules/aiModule/aiConfigRuntime.js +135 -0
- package/app/modules/aiModule/catCommand.js +967 -0
- package/app/modules/aiModule/commandConfig.json +981 -0
- package/app/modules/analyticsModule/messageAnalysisEventRepository.js +83 -0
- package/app/modules/gameModule/AGENT.md +196 -0
- package/app/modules/gameModule/commandConfig.json +366 -0
- package/app/modules/gameModule/diceCommand.js +42 -0
- package/app/modules/gameModule/gameAiHelpService.js +14 -0
- package/app/modules/gameModule/gameConfigRuntime.js +68 -0
- package/app/modules/menuModule/AGENT.md +205 -0
- package/app/modules/menuModule/commandConfig.json +366 -0
- package/app/modules/menuModule/common.js +316 -0
- package/app/modules/menuModule/menuAiHelpService.js +14 -0
- package/app/modules/menuModule/menuConfigRuntime.js +68 -0
- package/app/modules/menuModule/menus.js +66 -0
- package/app/modules/playModule/AGENT.md +321 -0
- package/app/modules/playModule/commandConfig.json +584 -0
- package/app/modules/playModule/playAiHelpService.js +14 -0
- package/app/modules/playModule/playCommand.js +1417 -0
- package/app/modules/playModule/playConfigRuntime.js +68 -0
- package/app/modules/quoteModule/AGENT.md +199 -0
- package/app/modules/quoteModule/commandConfig.json +366 -0
- package/app/modules/quoteModule/quoteAiHelpService.js +14 -0
- package/app/modules/quoteModule/quoteCommand.js +842 -0
- package/app/modules/quoteModule/quoteConfigRuntime.js +68 -0
- package/app/modules/rpgPokemonModule/AGENT.md +229 -0
- package/app/modules/rpgPokemonModule/commandConfig.json +386 -0
- package/app/modules/rpgPokemonModule/rpgBattleCanvasRenderer.js +795 -0
- package/app/modules/rpgPokemonModule/rpgBattleService.js +2110 -0
- package/app/modules/rpgPokemonModule/rpgBattleService.test.js +770 -0
- package/app/modules/rpgPokemonModule/rpgEvolutionUtils.js +22 -0
- package/app/modules/rpgPokemonModule/rpgPokemonAiHelpService.js +14 -0
- package/app/modules/rpgPokemonModule/rpgPokemonCommand.js +174 -0
- package/app/modules/rpgPokemonModule/rpgPokemonConfigRuntime.js +68 -0
- package/app/modules/rpgPokemonModule/rpgPokemonDomain.js +192 -0
- package/app/modules/rpgPokemonModule/rpgPokemonDomain.test.js +93 -0
- package/app/modules/rpgPokemonModule/rpgPokemonEvolution.test.js +46 -0
- package/app/modules/rpgPokemonModule/rpgPokemonMessages.js +746 -0
- package/app/modules/rpgPokemonModule/rpgPokemonRepository.js +1847 -0
- package/app/modules/rpgPokemonModule/rpgPokemonService.js +6839 -0
- package/app/modules/rpgPokemonModule/rpgProfileCanvasRenderer.js +354 -0
- package/app/modules/statsModule/AGENT.md +320 -0
- package/app/modules/statsModule/commandConfig.json +540 -0
- package/app/modules/statsModule/globalRankingCommand.js +64 -0
- package/app/modules/statsModule/rankingCommand.js +41 -0
- package/app/modules/statsModule/rankingCommon.js +1305 -0
- package/app/modules/statsModule/statsAiHelpService.js +14 -0
- package/app/modules/statsModule/statsConfigRuntime.js +68 -0
- package/app/modules/stickerModule/AGENT.md +692 -0
- package/app/modules/stickerModule/addStickerMetadata.js +239 -0
- package/app/modules/stickerModule/commandConfig.json +1216 -0
- package/app/modules/stickerModule/convertToWebp.js +367 -0
- package/app/modules/stickerModule/stickerAiHelpService.js +14 -0
- package/app/modules/stickerModule/stickerCommand.js +446 -0
- package/app/modules/stickerModule/stickerConfigRuntime.js +68 -0
- package/app/modules/stickerModule/stickerConvertCommand.js +159 -0
- package/app/modules/stickerModule/stickerTextCommand.js +653 -0
- package/app/modules/stickerPackModule/AGENT.md +215 -0
- package/app/modules/stickerPackModule/autoPackCollectorRuntime.js +20 -0
- package/app/modules/stickerPackModule/autoPackCollectorService.js +357 -0
- package/app/modules/stickerPackModule/commandConfig.json +387 -0
- package/app/modules/stickerPackModule/domainEventOutboxRepository.js +227 -0
- package/app/modules/stickerPackModule/domainEvents.js +52 -0
- package/app/modules/stickerPackModule/semanticReclassificationEngine.js +429 -0
- package/app/modules/stickerPackModule/semanticReclassificationEngine.test.js +75 -0
- package/app/modules/stickerPackModule/semanticThemeClusterService.js +544 -0
- package/app/modules/stickerPackModule/stickerAssetClassificationRepository.js +400 -0
- package/app/modules/stickerPackModule/stickerAssetRepository.js +400 -0
- package/app/modules/stickerPackModule/stickerAssetReprocessQueueRepository.js +175 -0
- package/app/modules/stickerPackModule/stickerAutoPackByTagsRuntime.js +3702 -0
- package/app/modules/stickerPackModule/stickerClassificationBackgroundRuntime.js +559 -0
- package/app/modules/stickerPackModule/stickerClassificationService.js +557 -0
- package/app/modules/stickerPackModule/stickerDedicatedTaskWorkerRuntime.js +249 -0
- package/app/modules/stickerPackModule/stickerDomainEventBus.js +65 -0
- package/app/modules/stickerPackModule/stickerDomainEventConsumerRuntime.js +208 -0
- package/app/modules/stickerPackModule/stickerMarketplaceDriftService.js +99 -0
- package/app/modules/stickerPackModule/stickerObjectStorageService.js +285 -0
- package/app/modules/stickerPackModule/stickerPackAiHelpService.js +14 -0
- package/app/modules/stickerPackModule/stickerPackCommandHandlers.js +1148 -0
- package/app/modules/stickerPackModule/stickerPackConfigRuntime.js +68 -0
- package/app/modules/stickerPackModule/stickerPackEngagementRepository.js +152 -0
- package/app/modules/stickerPackModule/stickerPackErrors.js +30 -0
- package/app/modules/stickerPackModule/stickerPackInteractionEventRepository.js +101 -0
- package/app/modules/stickerPackModule/stickerPackItemRepository.js +432 -0
- package/app/modules/stickerPackModule/stickerPackMarketplaceService.js +313 -0
- package/app/modules/stickerPackModule/stickerPackMessageService.js +268 -0
- package/app/modules/stickerPackModule/stickerPackRepository.js +450 -0
- package/app/modules/stickerPackModule/stickerPackScoreSnapshotRepository.js +179 -0
- package/app/modules/stickerPackModule/stickerPackScoreSnapshotRuntime.js +271 -0
- package/app/modules/stickerPackModule/stickerPackService.js +733 -0
- package/app/modules/stickerPackModule/stickerPackServiceRuntime.js +32 -0
- package/app/modules/stickerPackModule/stickerPackUtils.js +107 -0
- package/app/modules/stickerPackModule/stickerStorageService.js +559 -0
- package/app/modules/stickerPackModule/stickerWorkerPipelineRuntime.js +242 -0
- package/app/modules/stickerPackModule/stickerWorkerTaskQueueRepository.js +242 -0
- package/app/modules/systemMetricsModule/AGENT.md +193 -0
- package/app/modules/systemMetricsModule/commandConfig.json +344 -0
- package/app/modules/systemMetricsModule/pingCommand.js +399 -0
- package/app/modules/systemMetricsModule/systemMetricsAiHelpService.js +14 -0
- package/app/modules/systemMetricsModule/systemMetricsConfigRuntime.js +68 -0
- package/app/modules/tiktokModule/AGENT.md +196 -0
- package/app/modules/tiktokModule/commandConfig.json +366 -0
- package/app/modules/tiktokModule/tiktokAiHelpService.js +14 -0
- package/app/modules/tiktokModule/tiktokCommand.js +716 -0
- package/app/modules/tiktokModule/tiktokConfigRuntime.js +68 -0
- package/app/modules/userModule/AGENT.md +200 -0
- package/app/modules/userModule/commandConfig.json +386 -0
- package/app/modules/userModule/userAiHelpService.js +14 -0
- package/app/modules/userModule/userCommand.js +1155 -0
- package/app/modules/userModule/userConfigRuntime.js +68 -0
- package/app/modules/waifuPicsModule/AGENT.md +431 -0
- package/app/modules/waifuPicsModule/commandConfig.json +780 -0
- package/app/modules/waifuPicsModule/waifuPicsAiHelpService.js +14 -0
- package/app/modules/waifuPicsModule/waifuPicsCommand.js +586 -0
- package/app/modules/waifuPicsModule/waifuPicsConfigRuntime.js +68 -0
- package/app/observability/metrics.js +766 -0
- package/app/services/ai/aiHelpResponseCacheRepository.js +280 -0
- package/app/services/ai/aiLearningRepository.js +400 -0
- package/app/services/ai/commandConfigEnrichmentRepository.js +769 -0
- package/app/services/ai/commandConfigEnrichmentService.js +452 -0
- package/app/services/ai/commandConfigValidationService.js +443 -0
- package/app/services/ai/commandToolBuilderService.js +192 -0
- package/app/services/ai/conversationRouterService.js +516 -0
- package/app/services/ai/geminiService.js +115 -0
- package/app/services/ai/geminiService.test.js +87 -0
- package/app/services/ai/globalModuleAiHelpService.js +1412 -0
- package/app/services/ai/globalToolCallingService.js +203 -0
- package/app/services/ai/messageCommandExecutionService.js +391 -0
- package/app/services/ai/moduleAiHelpCoreService.js +1099 -0
- package/app/services/ai/moduleAiHelpWrapperFactory.js +65 -0
- package/app/services/ai/moduleCommandConfigRuntimeService.js +113 -0
- package/app/services/ai/moduleToolExecutorService.js +464 -0
- package/app/services/ai/moduleToolRegistryService.js +178 -0
- package/app/services/ai/toolCandidateSelectorService.js +781 -0
- package/app/services/auth/googleWebLinkService.js +80 -0
- package/app/services/auth/whatsappLoginLinkService.js +230 -0
- package/app/services/external/pokeApiService.js +398 -0
- package/app/services/group/groupMetadataService.js +311 -0
- package/app/services/infra/dbWriteQueue.js +874 -0
- package/app/services/infra/featureFlagService.js +131 -0
- package/app/services/infra/queueUtils.js +55 -0
- package/app/services/messaging/captchaService.js +491 -0
- package/app/services/messaging/messagePersistenceService.js +1 -0
- package/app/services/messaging/newsBroadcastService.js +347 -0
- package/app/services/sticker/stickerFocusService.js +347 -0
- package/app/services/sticker/stickerFocusService.test.js +43 -0
- package/app/store/aiPromptStore.js +38 -0
- package/app/store/conversationSessionStore.js +131 -0
- package/app/store/groupConfigStore.js +58 -0
- package/app/store/premiumUserStore.js +54 -0
- package/app/utils/antiLink/antiLinkModule.js +700 -0
- package/app/utils/http/getImageBufferModule.js +18 -0
- package/app/utils/json/jsonSanitizer.js +113 -0
- package/app/utils/json/jsonSanitizer.test.js +40 -0
- package/app/utils/systemMetrics/systemMetricsModule.js +88 -0
- package/app/workers/aiLearningWorker.js +605 -0
- package/app/workers/commandConfigEnrichmentWorker.js +242 -0
- package/database/index.js +2075 -0
- package/database/init.js +151 -0
- package/database/migrations/.gitkeep +0 -0
- package/database/migrations/20260307_d0_hardening_down.sql +64 -0
- package/database/migrations/20260307_d0_hardening_up.sql +79 -0
- package/database/migrations/20260307_d1_terms_acceptance_down.sql +11 -0
- package/database/migrations/20260307_d1_terms_acceptance_up.sql +37 -0
- package/database/migrations/20260307_d2_auth_hardening_down.sql +75 -0
- package/database/migrations/20260307_d2_auth_hardening_up.sql +100 -0
- package/database/migrations/20260314_d7_canonical_sender_down.sql +53 -0
- package/database/migrations/20260314_d7_canonical_sender_up.sql +114 -0
- package/database/migrations/20260406_d30_security_analytics_down.sql +95 -0
- package/database/migrations/20260406_d30_security_analytics_up.sql +292 -0
- package/database/migrations/20260407_d31_web_google_session_token_hardening_down.sql +2 -0
- package/database/migrations/20260407_d31_web_google_session_token_hardening_up.sql +17 -0
- package/database/migrations/20260408_d32_ai_help_response_cache_down.sql +1 -0
- package/database/migrations/20260408_d32_ai_help_response_cache_up.sql +22 -0
- package/database/migrations/20260409_d33_ai_learning_tables_down.sql +4 -0
- package/database/migrations/20260409_d33_ai_learning_tables_up.sql +52 -0
- package/database/migrations/20260410_d34_command_config_enrichment_down.sql +3 -0
- package/database/migrations/20260410_d34_command_config_enrichment_up.sql +48 -0
- package/database/schema.sql +1186 -0
- package/docker-compose.yml +104 -0
- package/docs/audits/stickerCatalogController-out-of-scope.md +103 -0
- package/docs/audits/stickerCatalogController-symbols.md +58 -0
- package/docs/compliance/acceptable-use-policy-2026-03-07.md +35 -0
- package/docs/compliance/dpa-b2b-standard-2026-03-07.md +80 -0
- package/docs/compliance/monthly-compliance-checklist-2026-03-07.md +88 -0
- package/docs/compliance/notice-and-takedown-policy-2026-03-07.md +34 -0
- package/docs/compliance/privacy-policy-2026-03-07.md +75 -0
- package/docs/compliance/subprocessors-inventory-2026-03-07.md +16 -0
- package/docs/database/production-db-evolution-runbook-2026q1.md +365 -0
- package/docs/security/dsar-lgpd-runbook-2026-03-07.md +86 -0
- package/docs/security/incident-response-lgpd-anpd-runbook-2026-03-07.md +77 -0
- package/docs/security/network-hardening-runbook-2026-03-07.md +137 -0
- package/docs/seo/omnizap-seo-playbook-br-2026-02-28.md +238 -0
- package/docs/seo/satellite-page-template.md +116 -0
- package/docs/seo/satellite-pages-phase1.json +364 -0
- package/docs/wiki/Home.md +120 -0
- package/docs/wiki/pair-extraordinaire-2026-03-08.md +3 -0
- package/docs/wiki/recent-changes-2026-03-08.md +47 -0
- package/ecosystem.prod.config.cjs +135 -0
- package/eslint.config.js +89 -0
- package/index.js +488 -0
- package/ml/clip_classifier/Dockerfile +18 -0
- package/ml/clip_classifier/README.md +118 -0
- package/ml/clip_classifier/adaptive_scoring.py +40 -0
- package/ml/clip_classifier/classifier.py +654 -0
- package/ml/clip_classifier/embedding_store.py +481 -0
- package/ml/clip_classifier/env_loader.py +15 -0
- package/ml/clip_classifier/llm_label_expander.py +144 -0
- package/ml/clip_classifier/main.py +213 -0
- package/ml/clip_classifier/requirements.txt +10 -0
- package/ml/clip_classifier/similarity_engine.py +74 -0
- package/new-logo.png +0 -0
- package/observability/alert-rules.yml +60 -0
- package/observability/grafana/dashboards/omnizap-mysql.json +136 -0
- package/observability/grafana/dashboards/omnizap-overview.json +170 -0
- package/observability/grafana/provisioning/dashboards/dashboards.yml +11 -0
- package/observability/grafana/provisioning/datasources/datasources.yml +15 -0
- package/observability/loki-config.yml +38 -0
- package/observability/mysql-setup.sql +46 -0
- package/observability/prometheus.yml +35 -0
- package/observability/promtail-config.yml +84 -0
- package/observability/sticker-catalog-slo.md +83 -0
- package/observability/sticker-scale-hardening-rollout.md +128 -0
- package/package.json +144 -0
- package/public/apple-touch-icon.png +0 -0
- package/public/assets/css/commands-react.input.css +71 -0
- package/public/assets/css/create-pack-react.input.css +31 -0
- package/public/assets/css/home-react.input.css +106 -0
- package/public/assets/css/login-react.input.css +58 -0
- package/public/assets/css/stickers-react.input.css +18 -0
- package/public/assets/css/terms-react.input.css +115 -0
- package/public/assets/css/user-react.input.css +57 -0
- package/public/assets/images/brand-icon-192.png +0 -0
- package/public/assets/images/brand-logo-128.webp +0 -0
- package/public/assets/images/hero-banner-1280.jpg +0 -0
- package/public/comandos/commands-catalog.json +4517 -0
- package/public/css/api-docs.css +161 -0
- package/public/css/stickers-admin.css +1288 -0
- package/public/css/styles.css +679 -0
- package/public/css/systemadm/admin.css +474 -0
- package/public/css/systemadm/base.css +73 -0
- package/public/css/systemadm/components.css +662 -0
- package/public/css/systemadm/layout.css +229 -0
- package/public/css/systemadm/tokens.css +56 -0
- package/public/favicon-16x16.png +0 -0
- package/public/favicon-32x32.png +0 -0
- package/public/favicon.ico +0 -0
- package/public/js/apps/apiDocsApp.js +235 -0
- package/public/js/apps/commandsReactApp.js +528 -0
- package/public/js/apps/createPackApp.js +1646 -0
- package/public/js/apps/homeReactApp.js +942 -0
- package/public/js/apps/loginReactApp.js +496 -0
- package/public/js/apps/stickersAdminApp.js +1753 -0
- package/public/js/apps/stickersApp.js +3797 -0
- package/public/js/apps/termsReactApp.js +528 -0
- package/public/js/apps/userApp.js +2540 -0
- package/public/js/apps/userProfile/actions.js +66 -0
- package/public/js/apps/userReactApp.js +547 -0
- package/public/js/catalog.js +950 -0
- package/public/pages/api-docs.html +40 -0
- package/public/pages/aup.html +158 -0
- package/public/pages/comandos.html +41 -0
- package/public/pages/dpa.html +227 -0
- package/public/pages/home.html +45 -0
- package/public/pages/licenca.html +182 -0
- package/public/pages/login.html +40 -0
- package/public/pages/notice-and-takedown.html +234 -0
- package/public/pages/politica-de-privacidade.html +251 -0
- package/public/pages/seo-bot-whatsapp-para-grupo.html +350 -0
- package/public/pages/seo-bot-whatsapp-sem-programar.html +350 -0
- package/public/pages/seo-como-automatizar-avisos-no-whatsapp.html +350 -0
- package/public/pages/seo-como-criar-comandos-whatsapp.html +350 -0
- package/public/pages/seo-como-evitar-spam-no-whatsapp.html +350 -0
- package/public/pages/seo-como-moderar-grupo-whatsapp.html +350 -0
- package/public/pages/seo-como-organizar-comunidade-whatsapp.html +350 -0
- package/public/pages/seo-melhor-bot-whatsapp-para-grupos.html +350 -0
- package/public/pages/stickers-admin.html +31 -0
- package/public/pages/stickers-create.html +41 -0
- package/public/pages/stickers.html +45 -0
- package/public/pages/suboperadores.html +237 -0
- package/public/pages/termos-de-uso-texto-integral.html +241 -0
- package/public/pages/termos-de-uso.html +41 -0
- package/public/pages/user-password-reset.html +32 -0
- package/public/pages/user-systemadm.html +508 -0
- package/public/pages/user.html +39 -0
- package/public/robots.txt +9 -0
- package/public/site.webmanifest +24 -0
- package/public/sitemap.xml +98 -0
- package/schemas/command-config.schema.json +582 -0
- package/scripts/baileys-compat-smoke.mjs +12 -0
- package/scripts/cache-bust.mjs +142 -0
- package/scripts/deploy.sh +916 -0
- package/scripts/email-broadcast-terms-update.mjs +170 -0
- package/scripts/enrich-command-discovery-fields.mjs +286 -0
- package/scripts/generate-command-config-schema.mjs +273 -0
- package/scripts/generate-commands-catalog.mjs +308 -0
- package/scripts/generate-module-agents.mjs +631 -0
- package/scripts/generate-seo-satellite-pages.mjs +400 -0
- package/scripts/github-deploy-notify.mjs +174 -0
- package/scripts/github-release-notify.mjs +219 -0
- package/scripts/release.sh +599 -0
- package/scripts/run-codeql-local.sh +116 -0
- package/scripts/run-prettier-all.mjs +25 -0
- package/scripts/security-smoketest.mjs +581 -0
- package/scripts/sticker-catalog-loadtest.mjs +210 -0
- package/scripts/sticker-worker-task.mjs +119 -0
- package/scripts/sync-readme-snapshot.mjs +133 -0
- package/scripts/validate-command-config-schema.mjs +130 -0
- package/scripts/validate-command-configs.mjs +15 -0
- package/scripts/wiki-sync.sh +191 -0
- package/server/auth/googleWebAuth/googleWebAuthRuntime.js +62 -0
- package/server/auth/googleWebAuth/googleWebAuthService.js +807 -0
- package/server/auth/jwt/webJwtService.js +147 -0
- package/server/auth/stickerCatalogAuthContext.js +165 -0
- package/server/auth/termsAcceptance/termsAcceptanceHandler.js +189 -0
- package/server/auth/userPassword/index.js +14 -0
- package/server/auth/userPassword/userPasswordAuthService.js +422 -0
- package/server/auth/userPassword/userPasswordCrypto.js +199 -0
- package/server/auth/userPassword/userPasswordCrypto.test.js +76 -0
- package/server/auth/userPassword/userPasswordRecoveryService.js +728 -0
- package/server/auth/validation/authSchemas.js +236 -0
- package/server/auth/webAccount/webAccountHandlers.js +1434 -0
- package/server/controllers/admin/adminBanService.js +138 -0
- package/server/controllers/admin/adminPanelHandlers.js +2083 -0
- package/server/controllers/admin/stickerCatalogAdminContext.js +17 -0
- package/server/controllers/admin/systemAdminController.js +201 -0
- package/server/controllers/email/emailAutomationController.js +239 -0
- package/server/controllers/metricsController.js +21 -0
- package/server/controllers/seo/stickerCatalogSeoContext.js +514 -0
- package/server/controllers/sticker/nonCatalogHandlers.js +303 -0
- package/server/controllers/sticker/stickerCatalogController.js +4700 -0
- package/server/controllers/system/contactController.js +115 -0
- package/server/controllers/system/githubController.js +137 -0
- package/server/controllers/system/stickerCatalogSystemContext.js +758 -0
- package/server/controllers/system/storageController.js +154 -0
- package/server/controllers/system/systemController.js +135 -0
- package/server/controllers/system/systemMetricsController.js +156 -0
- package/server/controllers/system/visitController.js +90 -0
- package/server/controllers/userController.js +145 -0
- package/server/email/emailAutomationRuntime.js +225 -0
- package/server/email/emailAutomationService.js +125 -0
- package/server/email/emailOutboxRepository.js +282 -0
- package/server/email/emailTemplateService.js +480 -0
- package/server/email/emailTransportService.js +156 -0
- package/server/http/clientIp.js +95 -0
- package/server/http/httpRequestUtils.js +262 -0
- package/server/http/httpRequestUtils.test.js +80 -0
- package/server/http/httpServer.js +180 -0
- package/server/http/requestContext.js +20 -0
- package/server/http/siteRoutingUtils.js +87 -0
- package/server/index.js +1 -0
- package/server/middleware/cachePolicy.js +26 -0
- package/server/middleware/cachePolicyHelpers.js +1 -0
- package/server/middleware/endpointRateLimit.js +181 -0
- package/server/middleware/rateLimit.js +70 -0
- package/server/middleware/requireAdminAuth.js +48 -0
- package/server/middleware/securityHeaders.js +97 -0
- package/server/routes/admin/systemAdminRouter.js +64 -0
- package/server/routes/email/emailAutomationRouter.js +46 -0
- package/server/routes/health/healthRouter.js +41 -0
- package/server/routes/indexRouter.js +234 -0
- package/server/routes/metrics/metricsRouter.js +58 -0
- package/server/routes/static/staticPageRouter.js +134 -0
- package/server/routes/sticker/catalogHandlers/catalogAdminHttp.js +105 -0
- package/server/routes/sticker/catalogHandlers/catalogAuthHttp.js +77 -0
- package/server/routes/sticker/catalogHandlers/catalogPublicHttp.js +120 -0
- package/server/routes/sticker/catalogHandlers/catalogUploadHttp.js +83 -0
- package/server/routes/sticker/catalogRouter.js +77 -0
- package/server/routes/sticker/stickerApiRouter.js +84 -0
- package/server/routes/sticker/stickerDataRouter.js +145 -0
- package/server/routes/sticker/stickerSiteRouter.js +43 -0
- package/server/routes/user/userApiPaths.js +66 -0
- package/server/routes/user/userRouter.js +65 -0
- package/server/utils/safePath.js +26 -0
- package/utils/logger/loggerModule.js +35 -0
- package/vite.config.mjs +38 -0
|
@@ -0,0 +1,1646 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
import htm from 'htm';
|
|
4
|
+
|
|
5
|
+
const html = htm.bind(React.createElement);
|
|
6
|
+
const CREATE_PACK_DRAFT_KEY = 'omnizap_create_pack_draft_v1';
|
|
7
|
+
const CREATE_PACK_DRAFT_MAX_CHARS = 3_500_000;
|
|
8
|
+
const PACK_UPLOAD_TASK_KEY = 'omnizap_pack_upload_task_v1';
|
|
9
|
+
const GOOGLE_AUTH_CACHE_KEY = 'omnizap_google_web_auth_cache_v1';
|
|
10
|
+
const GOOGLE_AUTH_CACHE_MAX_STALE_MS = 8 * 24 * 60 * 60 * 1000;
|
|
11
|
+
const MAX_MANUAL_TAGS = 8;
|
|
12
|
+
const DEFAULT_SUGGESTED_TAGS = ['anime', 'meme', 'game', 'texto', 'nsfw', 'dark', 'cartoon', 'foto-real', 'cyberpunk'];
|
|
13
|
+
const PACK_STATUS_PUBLISHED = 'published';
|
|
14
|
+
const FIXED_UPLOAD_QUEUE_CONCURRENCY = 3;
|
|
15
|
+
const UPLOAD_AUTO_RETRY_ATTEMPTS = 2;
|
|
16
|
+
const UPLOAD_RETRY_BASE_DELAY_MS = 700;
|
|
17
|
+
|
|
18
|
+
const DEFAULT_LIMITS = {
|
|
19
|
+
pack_name_max_length: 120,
|
|
20
|
+
publisher_max_length: 120,
|
|
21
|
+
description_max_length: 1024,
|
|
22
|
+
stickers_per_pack: 30,
|
|
23
|
+
packs_per_owner: 50,
|
|
24
|
+
sticker_upload_max_bytes: 2 * 1024 * 1024,
|
|
25
|
+
sticker_upload_source_max_bytes: 20 * 1024 * 1024,
|
|
26
|
+
};
|
|
27
|
+
const UPLOAD_REQUEST_TIMEOUT_MS = 8 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
const STEPS = [
|
|
30
|
+
{ id: 1, title: 'Informações' },
|
|
31
|
+
{ id: 2, title: 'Stickers' },
|
|
32
|
+
{ id: 3, title: 'Publicação' },
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const clampText = (value, maxLength) =>
|
|
36
|
+
String(value || '')
|
|
37
|
+
.replace(/\s+/g, ' ')
|
|
38
|
+
.trim()
|
|
39
|
+
.slice(0, maxLength);
|
|
40
|
+
|
|
41
|
+
const clampInputText = (value, maxLength) => String(value || '').slice(0, maxLength);
|
|
42
|
+
|
|
43
|
+
const removeControlChars = (value) =>
|
|
44
|
+
Array.from(String(value || ''))
|
|
45
|
+
.filter((char) => {
|
|
46
|
+
const code = char.charCodeAt(0);
|
|
47
|
+
return code > 0x1f && code !== 0x7f;
|
|
48
|
+
})
|
|
49
|
+
.join('');
|
|
50
|
+
|
|
51
|
+
const sanitizePackNameInput = (value, maxLength = 120) => removeControlChars(value).slice(0, maxLength);
|
|
52
|
+
|
|
53
|
+
const sanitizePackName = (value, maxLength = 120) => removeControlChars(value).replace(/\s+/g, ' ').trim().slice(0, maxLength);
|
|
54
|
+
|
|
55
|
+
const toBytesLabel = (bytes) => `${Math.round(Number(bytes || 0) / 1024)} KB`;
|
|
56
|
+
const normalizeTag = (value) =>
|
|
57
|
+
String(value || '')
|
|
58
|
+
.trim()
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.normalize('NFD')
|
|
61
|
+
.replace(/[\u0300-\u036f]/g, '')
|
|
62
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
63
|
+
.replace(/^-+|-+$/g, '')
|
|
64
|
+
.slice(0, 40);
|
|
65
|
+
|
|
66
|
+
const mergeTags = (...groups) => {
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
const ordered = [];
|
|
69
|
+
for (const group of groups) {
|
|
70
|
+
for (const entry of Array.isArray(group) ? group : []) {
|
|
71
|
+
const normalized = normalizeTag(entry);
|
|
72
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
73
|
+
seen.add(normalized);
|
|
74
|
+
ordered.push(normalized);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return ordered;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const normalizeGoogleAuthState = (value) => {
|
|
81
|
+
const user = value?.user && typeof value.user === 'object' ? value.user : null;
|
|
82
|
+
const sub = String(user?.sub || '').trim();
|
|
83
|
+
if (!sub) return { user: null, expiresAt: '' };
|
|
84
|
+
return {
|
|
85
|
+
user: {
|
|
86
|
+
sub,
|
|
87
|
+
email: String(user?.email || '').trim(),
|
|
88
|
+
name: String(user?.name || 'Conta Google').trim() || 'Conta Google',
|
|
89
|
+
picture: String(user?.picture || '').trim(),
|
|
90
|
+
},
|
|
91
|
+
expiresAt: String(value?.expiresAt || '').trim(),
|
|
92
|
+
};
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const readGoogleAuthCache = () => {
|
|
96
|
+
try {
|
|
97
|
+
const raw = localStorage.getItem(GOOGLE_AUTH_CACHE_KEY);
|
|
98
|
+
if (!raw) return null;
|
|
99
|
+
const parsed = JSON.parse(raw);
|
|
100
|
+
const savedAt = Number(parsed?.savedAt || 0);
|
|
101
|
+
if (savedAt && Date.now() - savedAt > GOOGLE_AUTH_CACHE_MAX_STALE_MS) {
|
|
102
|
+
localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
const normalized = normalizeGoogleAuthState(parsed?.auth || null);
|
|
106
|
+
if (!normalized.user?.sub) {
|
|
107
|
+
localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
if (normalized.expiresAt) {
|
|
111
|
+
const expiresAt = Number(new Date(normalized.expiresAt));
|
|
112
|
+
if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
|
|
113
|
+
localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return normalized;
|
|
118
|
+
} catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const writeGoogleAuthCache = (authState) => {
|
|
124
|
+
try {
|
|
125
|
+
const normalized = normalizeGoogleAuthState(authState);
|
|
126
|
+
if (!normalized.user?.sub) {
|
|
127
|
+
localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
localStorage.setItem(
|
|
131
|
+
GOOGLE_AUTH_CACHE_KEY,
|
|
132
|
+
JSON.stringify({
|
|
133
|
+
auth: normalized,
|
|
134
|
+
savedAt: Date.now(),
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
} catch {
|
|
138
|
+
// ignore storage errors
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const clearGoogleAuthCache = () => {
|
|
143
|
+
try {
|
|
144
|
+
localStorage.removeItem(GOOGLE_AUTH_CACHE_KEY);
|
|
145
|
+
} catch {
|
|
146
|
+
// ignore storage errors
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
const fetchJson = async (url, options = {}) => {
|
|
151
|
+
const response = await fetch(url, { credentials: 'include', ...options });
|
|
152
|
+
const payload = await response.json().catch(() => ({}));
|
|
153
|
+
if (!response.ok) {
|
|
154
|
+
throw new Error(payload?.error || 'Falha na requisição.');
|
|
155
|
+
}
|
|
156
|
+
return payload;
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, Number(ms || 0))));
|
|
160
|
+
|
|
161
|
+
const bytesToHex = (bufferLike) => {
|
|
162
|
+
const bytes = bufferLike instanceof Uint8Array ? bufferLike : new Uint8Array(bufferLike || []);
|
|
163
|
+
return Array.from(bytes)
|
|
164
|
+
.map((byte) => byte.toString(16).padStart(2, '0'))
|
|
165
|
+
.join('');
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const computeDataUrlSha256 = async (dataUrl) => {
|
|
169
|
+
try {
|
|
170
|
+
const raw = String(dataUrl || '');
|
|
171
|
+
const base64 = raw.includes(',') ? raw.split(',').slice(1).join(',') : raw;
|
|
172
|
+
if (!base64) return '';
|
|
173
|
+
const subtle = globalThis.crypto?.subtle;
|
|
174
|
+
if (!subtle) return '';
|
|
175
|
+
const binary = atob(base64.replace(/\s+/g, ''));
|
|
176
|
+
const bytes = new Uint8Array(binary.length);
|
|
177
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
178
|
+
bytes[i] = binary.charCodeAt(i);
|
|
179
|
+
}
|
|
180
|
+
const digest = await subtle.digest('SHA-256', bytes);
|
|
181
|
+
return bytesToHex(digest);
|
|
182
|
+
} catch {
|
|
183
|
+
return '';
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const runAsyncQueue = async (items, worker, maxConcurrency = FIXED_UPLOAD_QUEUE_CONCURRENCY) => {
|
|
188
|
+
const list = Array.isArray(items) ? items : [];
|
|
189
|
+
if (!list.length) return [];
|
|
190
|
+
const concurrency = Math.max(1, Math.min(Number(maxConcurrency || 1), list.length));
|
|
191
|
+
const results = new Array(list.length);
|
|
192
|
+
let cursor = 0;
|
|
193
|
+
|
|
194
|
+
const runWorker = async () => {
|
|
195
|
+
while (cursor < list.length) {
|
|
196
|
+
const index = cursor;
|
|
197
|
+
cursor += 1;
|
|
198
|
+
results[index] = await worker(list[index], index);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
await Promise.all(Array.from({ length: concurrency }, () => runWorker()));
|
|
203
|
+
return results;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const isTransientUploadError = (error) => {
|
|
207
|
+
const statusCode = Number(error?.statusCode || 0);
|
|
208
|
+
if ([408, 429, 502, 503, 504].includes(statusCode)) return true;
|
|
209
|
+
const message = String(error?.message || '').toLowerCase();
|
|
210
|
+
return message.includes('rede') || message.includes('timeout') || message.includes('demorou');
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const writeUploadTask = (payload) => {
|
|
214
|
+
try {
|
|
215
|
+
localStorage.setItem(
|
|
216
|
+
PACK_UPLOAD_TASK_KEY,
|
|
217
|
+
JSON.stringify({
|
|
218
|
+
...payload,
|
|
219
|
+
updatedAt: Date.now(),
|
|
220
|
+
}),
|
|
221
|
+
);
|
|
222
|
+
} catch {
|
|
223
|
+
// ignore storage errors
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const clearCreatePackStorage = () => {
|
|
228
|
+
try {
|
|
229
|
+
localStorage.removeItem(CREATE_PACK_DRAFT_KEY);
|
|
230
|
+
localStorage.removeItem(PACK_UPLOAD_TASK_KEY);
|
|
231
|
+
} catch {
|
|
232
|
+
// ignore storage errors
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const fileToDataUrl = (file) =>
|
|
237
|
+
new Promise((resolve, reject) => {
|
|
238
|
+
const reader = new FileReader();
|
|
239
|
+
reader.onload = () => resolve(String(reader.result || ''));
|
|
240
|
+
reader.onerror = () => reject(new Error('Falha ao ler arquivo.'));
|
|
241
|
+
reader.readAsDataURL(file);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const uploadStickerWithProgress = ({ apiBasePath, packKey, editToken, item, setCover, onProgress }) =>
|
|
245
|
+
new Promise((resolve, reject) => {
|
|
246
|
+
const xhr = new XMLHttpRequest();
|
|
247
|
+
xhr.open('POST', `${apiBasePath}/${encodeURIComponent(packKey)}/stickers-upload`);
|
|
248
|
+
xhr.setRequestHeader('Content-Type', 'application/json; charset=utf-8');
|
|
249
|
+
xhr.timeout = UPLOAD_REQUEST_TIMEOUT_MS;
|
|
250
|
+
|
|
251
|
+
xhr.upload.onprogress = (event) => {
|
|
252
|
+
if (!event.lengthComputable) return;
|
|
253
|
+
const percentage = Math.max(0, Math.min(100, Math.round((event.loaded / event.total) * 100)));
|
|
254
|
+
onProgress(percentage);
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
xhr.onerror = () => {
|
|
258
|
+
const error = new Error(`Falha de rede ao enviar ${item.file.name}.`);
|
|
259
|
+
error.statusCode = 0;
|
|
260
|
+
reject(error);
|
|
261
|
+
};
|
|
262
|
+
xhr.ontimeout = () => {
|
|
263
|
+
const error = new Error(`Timeout ao enviar ${item.file.name}. Tente novamente.`);
|
|
264
|
+
error.statusCode = 408;
|
|
265
|
+
reject(error);
|
|
266
|
+
};
|
|
267
|
+
xhr.onload = () => {
|
|
268
|
+
let payload = {};
|
|
269
|
+
try {
|
|
270
|
+
payload = JSON.parse(xhr.responseText || '{}');
|
|
271
|
+
} catch {
|
|
272
|
+
payload = {};
|
|
273
|
+
}
|
|
274
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
275
|
+
resolve(payload);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (xhr.status === 413) {
|
|
279
|
+
const error = new Error(`Arquivo muito grande para enviar (${item.file.name}). Reduza o tamanho e tente novamente.`);
|
|
280
|
+
error.statusCode = 413;
|
|
281
|
+
error.code = payload?.code || '';
|
|
282
|
+
reject(error);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (xhr.status === 502 || xhr.status === 504) {
|
|
286
|
+
const error = new Error(`Servidor demorou para processar ${item.file.name}. Tente novamente em seguida.`);
|
|
287
|
+
error.statusCode = xhr.status;
|
|
288
|
+
error.code = payload?.code || '';
|
|
289
|
+
reject(error);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const error = new Error(payload?.error || `Falha no upload de ${item.file.name}.`);
|
|
293
|
+
error.statusCode = xhr.status;
|
|
294
|
+
error.code = payload?.code || '';
|
|
295
|
+
reject(error);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
const body = JSON.stringify({
|
|
299
|
+
edit_token: editToken,
|
|
300
|
+
upload_id: String(item.id || ''),
|
|
301
|
+
sticker_hash: String(item.hash || ''),
|
|
302
|
+
sticker_data_url: item.dataUrl,
|
|
303
|
+
set_cover: Boolean(setCover),
|
|
304
|
+
});
|
|
305
|
+
xhr.send(body);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const uploadStickerWithRetry = async (params) => {
|
|
309
|
+
let lastError = null;
|
|
310
|
+
|
|
311
|
+
for (let attempt = 1; attempt <= UPLOAD_AUTO_RETRY_ATTEMPTS; attempt += 1) {
|
|
312
|
+
try {
|
|
313
|
+
return await uploadStickerWithProgress(params);
|
|
314
|
+
} catch (error) {
|
|
315
|
+
lastError = error;
|
|
316
|
+
if (attempt >= UPLOAD_AUTO_RETRY_ATTEMPTS || !isTransientUploadError(error)) {
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
await sleep(UPLOAD_RETRY_BASE_DELAY_MS * attempt);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
throw lastError || new Error('Falha no upload do sticker.');
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
function StepPill({ step, active, done }) {
|
|
327
|
+
return html`
|
|
328
|
+
<div className=${`flex min-w-0 items-center gap-1.5 rounded-xl border px-2.5 py-1.5 transition sm:gap-2 sm:rounded-2xl sm:px-3 sm:py-2 ${active ? 'border-accent/50 bg-accent/10 text-accent' : done ? 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300' : 'border-line/70 bg-panelSoft/80 text-slate-300'}`}>
|
|
329
|
+
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-black/25 text-[11px] font-extrabold sm:h-6 sm:w-6 sm:text-xs"> ${done ? '✓' : step.id} </span>
|
|
330
|
+
<p className="truncate text-[10px] font-semibold sm:text-[11px]">${step.title}</p>
|
|
331
|
+
</div>
|
|
332
|
+
`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function FloatingField({ label, value, onChange, maxLength, hint = '', multiline = false }) {
|
|
336
|
+
const used = String(value || '').length;
|
|
337
|
+
const nearLimit = used >= maxLength * 0.85;
|
|
338
|
+
const atLimit = used >= maxLength;
|
|
339
|
+
const Tag = multiline ? 'textarea' : 'input';
|
|
340
|
+
|
|
341
|
+
return html`
|
|
342
|
+
<label className="block">
|
|
343
|
+
<span className="mb-1.5 inline-block text-xs font-semibold text-slate-300">${label}</span>
|
|
344
|
+
<div className="relative">
|
|
345
|
+
<${Tag} className=${`w-full rounded-2xl border bg-panel/70 px-3.5 py-2.5 text-sm text-slate-100 outline-none transition placeholder:text-transparent md:bg-panel/80 md:px-4 md:py-3 ${atLimit ? 'border-rose-400/60 focus:border-rose-300' : 'border-line focus:border-accent/60'} ${multiline ? 'min-h-[96px] max-h-44 resize-none overflow-y-auto md:min-h-[110px] md:max-h-52' : 'h-11 md:h-12'}`} placeholder=${label} value=${value} maxlength=${maxLength} onInput=${onChange} />
|
|
346
|
+
<span className="pointer-events-none absolute left-3.5 top-[-9px] rounded-md bg-app-base px-1.5 text-[10px] font-semibold uppercase tracking-[.08em] text-slate-400 md:left-4 md:bg-panel md:px-2"> ${label} </span>
|
|
347
|
+
</div>
|
|
348
|
+
<div className="mt-1.5 flex items-center justify-between gap-3 text-[11px]">
|
|
349
|
+
<span className="line-clamp-2 text-slate-400">${hint}</span>
|
|
350
|
+
<span className=${`${atLimit ? 'text-rose-300' : nearLimit ? 'text-amber-300' : 'text-slate-400'} font-semibold`}>${used}/${maxLength}</span>
|
|
351
|
+
</div>
|
|
352
|
+
</label>
|
|
353
|
+
`;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function StickerThumb({ item, index, selectedCoverId, onSetCover, onRemove, onDragStart, onDropOn }) {
|
|
357
|
+
return html`
|
|
358
|
+
<article draggable=${true} onDragStart=${() => onDragStart(item.id)} onDragOver=${(e) => e.preventDefault()} onDrop=${() => onDropOn(item.id)} className="group relative overflow-hidden rounded-2xl border border-line bg-panelSoft">
|
|
359
|
+
${item.mediaKind === 'video' ? html`<video src=${item.dataUrl} muted=${true} playsinline=${true} preload="metadata" className="aspect-square w-full object-cover bg-slate-900/80"></video>` : html`<img src=${item.dataUrl} alt=${item.file.name} className="aspect-square w-full object-contain bg-slate-900/80" />`}
|
|
360
|
+
<span className="absolute left-2 top-2 rounded-md bg-black/50 px-1.5 py-0.5 text-[10px] font-bold">#${index + 1}</span>
|
|
361
|
+
<div className="absolute inset-x-0 bottom-0 flex items-center justify-between gap-2 bg-gradient-to-t from-black/80 to-transparent p-2">
|
|
362
|
+
<button type="button" onClick=${() => onSetCover(item.id)} className=${`rounded-lg px-2 py-1 text-[10px] font-bold ${selectedCoverId === item.id ? 'bg-accent text-slate-900' : 'bg-white/15 text-slate-100'}`}>${selectedCoverId === item.id ? 'Capa' : 'Definir capa'}</button>
|
|
363
|
+
<button type="button" onClick=${() => onRemove(item.id)} className="rounded-lg bg-rose-500/80 px-2 py-1 text-[10px] font-bold text-white">Remover</button>
|
|
364
|
+
</div>
|
|
365
|
+
</article>
|
|
366
|
+
`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function PackPreviewPanel({ preview, quality, compact = false }) {
|
|
370
|
+
return html`
|
|
371
|
+
<div className="space-y-2">
|
|
372
|
+
<article className="min-w-0 overflow-hidden rounded-2xl border border-line/70 bg-panelSoft/80">
|
|
373
|
+
<img src=${preview.coverUrl} alt="Preview capa" className="aspect-square w-full object-cover bg-slate-900/70" />
|
|
374
|
+
<div className=${`${compact ? 'p-3' : 'p-4'} space-y-2`}>
|
|
375
|
+
<p className=${`${compact ? 'text-base' : 'text-lg'} line-clamp-2 font-display font-bold`}>${preview.name}</p>
|
|
376
|
+
<p className="line-clamp-2 text-sm text-slate-300">${preview.description || 'Descrição do pack aparecerá aqui.'}</p>
|
|
377
|
+
<p className="text-xs text-slate-400">por ${preview.publisher}</p>
|
|
378
|
+
<div className="flex flex-wrap items-center gap-1">${preview.tags.length ? preview.tags.map((tag) => html`<span key=${tag} className="rounded-full border border-line/70 px-2 py-0.5 text-[10px] text-slate-300">#${tag}</span>`) : html`<span className="text-[10px] text-slate-500">Adicione tags para melhorar descoberta</span>`}</div>
|
|
379
|
+
<div className="flex flex-wrap items-center gap-1.5 text-xs">
|
|
380
|
+
<span className="rounded-full border border-line/70 px-2 py-1 text-slate-300">${preview.visibility}</span>
|
|
381
|
+
<span className="rounded-full border border-line/70 px-2 py-1 text-slate-300">🧩 ${preview.stickerCount}</span>
|
|
382
|
+
<span className="rounded-full border border-line/70 px-2 py-1 text-slate-300">❤️ ${preview.fakeLikes}</span>
|
|
383
|
+
<span className="rounded-full border border-line/70 px-2 py-1 text-slate-300">⬇ ${preview.fakeOpens}</span>
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
</article>
|
|
387
|
+
|
|
388
|
+
<div className="rounded-2xl border border-line/60 bg-panelSoft/70 p-3">
|
|
389
|
+
<div className="flex items-center justify-between gap-2">
|
|
390
|
+
<p className="truncate text-xs font-semibold text-slate-200">Score: ${quality.score} · ${quality.label}</p>
|
|
391
|
+
<span className=${`${quality.tone} shrink-0 text-[11px] font-semibold`}>${quality.score}%</span>
|
|
392
|
+
</div>
|
|
393
|
+
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-slate-900/70">
|
|
394
|
+
<div className=${`h-full transition-all ${quality.bar}`} style=${{ width: `${quality.score}%` }}></div>
|
|
395
|
+
</div>
|
|
396
|
+
</div>
|
|
397
|
+
</div>
|
|
398
|
+
`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function CreatePackApp() {
|
|
402
|
+
const root = document.getElementById('create-pack-react-root');
|
|
403
|
+
const apiBasePath = root?.dataset?.apiBasePath || '/api/sticker-packs';
|
|
404
|
+
const webPath = root?.dataset?.webPath || '/stickers';
|
|
405
|
+
const loginPath = root?.dataset?.loginPath || '/login';
|
|
406
|
+
const googleSessionApiPath = `${apiBasePath}/auth/google/session`;
|
|
407
|
+
|
|
408
|
+
const [step, setStep] = useState(1);
|
|
409
|
+
const [limits, setLimits] = useState(DEFAULT_LIMITS);
|
|
410
|
+
const [name, setName] = useState('');
|
|
411
|
+
const [description, setDescription] = useState('');
|
|
412
|
+
const [publisher, setPublisher] = useState('');
|
|
413
|
+
const [visibility, setVisibility] = useState('public');
|
|
414
|
+
const [tags, setTags] = useState([]);
|
|
415
|
+
const [tagInput, setTagInput] = useState('');
|
|
416
|
+
const [suggestedTags, setSuggestedTags] = useState(DEFAULT_SUGGESTED_TAGS);
|
|
417
|
+
const [googleAuth, setGoogleAuth] = useState(() => readGoogleAuthCache() || { user: null, expiresAt: '' });
|
|
418
|
+
const [googleSessionChecked, setGoogleSessionChecked] = useState(false);
|
|
419
|
+
const [authRedirecting, setAuthRedirecting] = useState(false);
|
|
420
|
+
const [files, setFiles] = useState([]);
|
|
421
|
+
const [coverId, setCoverId] = useState('');
|
|
422
|
+
const [dragActive, setDragActive] = useState(false);
|
|
423
|
+
const [draggingStickerId, setDraggingStickerId] = useState('');
|
|
424
|
+
const [busy, setBusy] = useState(false);
|
|
425
|
+
const [status, setStatus] = useState('');
|
|
426
|
+
const [error, setError] = useState('');
|
|
427
|
+
const [publishPhase, setPublishPhase] = useState('idle');
|
|
428
|
+
const [progress, setProgress] = useState({ current: 0, total: 0 });
|
|
429
|
+
const [uploadMap, setUploadMap] = useState({});
|
|
430
|
+
const [activeSession, setActiveSession] = useState(null);
|
|
431
|
+
const [result, setResult] = useState(null);
|
|
432
|
+
const [backendPublishState, setBackendPublishState] = useState(null);
|
|
433
|
+
const [draftLoaded, setDraftLoaded] = useState(false);
|
|
434
|
+
const [mobilePreviewOpen, setMobilePreviewOpen] = useState(false);
|
|
435
|
+
const hasGoogleLogin = Boolean(googleAuth.user?.sub);
|
|
436
|
+
const buildLoginRedirectUrl = useCallback(() => {
|
|
437
|
+
const nextPath = `${window.location.pathname || '/'}${window.location.search || ''}`;
|
|
438
|
+
const loginUrl = new URL(loginPath, window.location.origin);
|
|
439
|
+
loginUrl.searchParams.set('next', nextPath);
|
|
440
|
+
return `${loginUrl.pathname}${loginUrl.search}`;
|
|
441
|
+
}, [loginPath]);
|
|
442
|
+
const redirectToLogin = useCallback(
|
|
443
|
+
(reason = 'Sessão ausente. Redirecionando para login...') => {
|
|
444
|
+
if (authRedirecting) return;
|
|
445
|
+
setAuthRedirecting(true);
|
|
446
|
+
setStatus(reason);
|
|
447
|
+
setError('');
|
|
448
|
+
window.location.assign(buildLoginRedirectUrl());
|
|
449
|
+
},
|
|
450
|
+
[authRedirecting, buildLoginRedirectUrl],
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
const canStep2 = useMemo(() => sanitizePackName(name, limits.pack_name_max_length).length > 0 && hasGoogleLogin && googleSessionChecked && !authRedirecting, [name, limits.pack_name_max_length, hasGoogleLogin, googleSessionChecked, authRedirecting]);
|
|
454
|
+
const canStep3 = useMemo(() => files.length > 0, [files.length]);
|
|
455
|
+
const publishReady = canStep2 && canStep3 && !busy;
|
|
456
|
+
const completionPercentage = Math.round((step / STEPS.length) * 100);
|
|
457
|
+
const failedUploadsCount = useMemo(() => files.reduce((acc, item) => (uploadMap[item.id]?.status === 'error' ? acc + 1 : acc), 0), [files, uploadMap]);
|
|
458
|
+
const pendingUploadsCount = useMemo(() => files.reduce((acc, item) => (uploadMap[item.id]?.status === 'done' ? acc : acc + 1), 0), [files, uploadMap]);
|
|
459
|
+
const hasPartialUploadedSession = Boolean(activeSession?.packKey && pendingUploadsCount > 0 && pendingUploadsCount < files.length);
|
|
460
|
+
const backendPackStatus = String(backendPublishState?.status || result?.status || activeSession?.created?.status || '').toLowerCase();
|
|
461
|
+
const publishLabel = backendPackStatus === 'failed' ? '🛠️ Reparar pack' : failedUploadsCount > 0 ? `🔁 Reenviar falhas (${failedUploadsCount})` : hasPartialUploadedSession ? '▶ Retomar envio' : '🚀 Publicar Pack';
|
|
462
|
+
|
|
463
|
+
const suggestedFromText = useMemo(() => {
|
|
464
|
+
const haystack = `${name} ${description}`
|
|
465
|
+
.toLowerCase()
|
|
466
|
+
.normalize('NFD')
|
|
467
|
+
.replace(/[\u0300-\u036f]/g, '');
|
|
468
|
+
const selected = new Set(tags);
|
|
469
|
+
const matches = [];
|
|
470
|
+
|
|
471
|
+
for (const tag of suggestedTags) {
|
|
472
|
+
const normalizedTag = normalizeTag(tag);
|
|
473
|
+
if (!normalizedTag || selected.has(normalizedTag)) continue;
|
|
474
|
+
const plain = normalizedTag.replace(/-/g, ' ');
|
|
475
|
+
const directMatch = haystack.includes(plain) || haystack.includes(normalizedTag);
|
|
476
|
+
if (directMatch) matches.push(normalizedTag);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (matches.length >= 5) return matches.slice(0, 5);
|
|
480
|
+
const fallback = suggestedTags.map((tag) => normalizeTag(tag)).filter((tag) => tag && !selected.has(tag));
|
|
481
|
+
return mergeTags(matches, fallback).slice(0, 5);
|
|
482
|
+
}, [name, description, suggestedTags, tags]);
|
|
483
|
+
|
|
484
|
+
const tagTypeaheadSuggestions = useMemo(() => {
|
|
485
|
+
const query = normalizeTag(tagInput);
|
|
486
|
+
if (!query) return [];
|
|
487
|
+
|
|
488
|
+
const selected = new Set(tags);
|
|
489
|
+
const startsWith = [];
|
|
490
|
+
const includes = [];
|
|
491
|
+
|
|
492
|
+
for (const tag of mergeTags(suggestedFromText, suggestedTags)) {
|
|
493
|
+
if (!tag || selected.has(tag) || tag === query) continue;
|
|
494
|
+
if (tag.startsWith(query)) {
|
|
495
|
+
startsWith.push(tag);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (tag.includes(query)) {
|
|
499
|
+
includes.push(tag);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
return [...startsWith, ...includes].slice(0, 6);
|
|
504
|
+
}, [tagInput, tags, suggestedFromText, suggestedTags]);
|
|
505
|
+
|
|
506
|
+
const preview = useMemo(() => {
|
|
507
|
+
const safeName = sanitizePackName(name, limits.pack_name_max_length) || 'novopack';
|
|
508
|
+
const safeDescription = clampText(description, limits.description_max_length);
|
|
509
|
+
const preferredCover = files.find((item) => item.id === coverId) || files[0] || null;
|
|
510
|
+
const imageFallback = files.find((item) => item.mediaKind !== 'video') || null;
|
|
511
|
+
const cover = preferredCover?.mediaKind === 'video' ? imageFallback : preferredCover;
|
|
512
|
+
return {
|
|
513
|
+
name: safeName,
|
|
514
|
+
description: safeDescription,
|
|
515
|
+
publisher: clampText(publisher || 'OmniZap Creator', limits.publisher_max_length),
|
|
516
|
+
coverUrl: cover?.dataUrl || 'https://iili.io/fSNGag2.png',
|
|
517
|
+
stickerCount: files.length,
|
|
518
|
+
visibility,
|
|
519
|
+
tags: tags.slice(0, 3),
|
|
520
|
+
fakeLikes: Math.max(12, files.length * 7 + 11),
|
|
521
|
+
fakeOpens: Math.max(100, files.length * 55 + 70),
|
|
522
|
+
};
|
|
523
|
+
}, [name, description, publisher, files, coverId, visibility, limits.description_max_length, limits.pack_name_max_length, limits.publisher_max_length, tags]);
|
|
524
|
+
|
|
525
|
+
const quality = useMemo(() => {
|
|
526
|
+
const titleLength = sanitizePackName(name, limits.pack_name_max_length).length;
|
|
527
|
+
const descriptionLength = clampText(description, limits.description_max_length).length;
|
|
528
|
+
const titleScore = titleLength >= 6 ? 1 : titleLength >= 4 ? 0.7 : 0;
|
|
529
|
+
const descriptionScore = descriptionLength >= 28 ? 1 : descriptionLength >= 14 ? 0.6 : 0;
|
|
530
|
+
const tagsScore = Math.min(1, tags.length / 4);
|
|
531
|
+
const stickersScore = Math.min(1, files.length / 12);
|
|
532
|
+
const coverScore = coverId ? 1 : files.length ? 0.6 : 0;
|
|
533
|
+
const finalScore = Math.round((titleScore * 0.28 + descriptionScore * 0.24 + tagsScore * 0.2 + stickersScore * 0.2 + coverScore * 0.08) * 100);
|
|
534
|
+
if (finalScore >= 80)
|
|
535
|
+
return {
|
|
536
|
+
score: finalScore,
|
|
537
|
+
label: 'Excelente',
|
|
538
|
+
tone: 'text-emerald-300',
|
|
539
|
+
bar: 'bg-emerald-400',
|
|
540
|
+
};
|
|
541
|
+
if (finalScore >= 60) return { score: finalScore, label: 'Bom', tone: 'text-amber-300', bar: 'bg-amber-400' };
|
|
542
|
+
return {
|
|
543
|
+
score: finalScore,
|
|
544
|
+
label: 'Precisa melhorar',
|
|
545
|
+
tone: 'text-rose-300',
|
|
546
|
+
bar: 'bg-rose-400',
|
|
547
|
+
};
|
|
548
|
+
}, [name, description, tags.length, files.length, coverId, limits.pack_name_max_length, limits.description_max_length]);
|
|
549
|
+
|
|
550
|
+
useEffect(() => {
|
|
551
|
+
const load = async () => {
|
|
552
|
+
try {
|
|
553
|
+
const payload = await fetchJson(`${apiBasePath}/create-config`);
|
|
554
|
+
const apiLimits = payload?.data?.limits || {};
|
|
555
|
+
const apiSuggestions = payload?.data?.rules?.suggested_tags;
|
|
556
|
+
setLimits((prev) => ({ ...prev, ...apiLimits }));
|
|
557
|
+
if (Array.isArray(apiSuggestions) && apiSuggestions.length) {
|
|
558
|
+
setSuggestedTags(mergeTags(apiSuggestions).slice(0, 20));
|
|
559
|
+
}
|
|
560
|
+
} catch {
|
|
561
|
+
// keep default
|
|
562
|
+
}
|
|
563
|
+
};
|
|
564
|
+
load();
|
|
565
|
+
}, [apiBasePath]);
|
|
566
|
+
|
|
567
|
+
useEffect(() => {
|
|
568
|
+
let cancelled = false;
|
|
569
|
+
setGoogleSessionChecked(false);
|
|
570
|
+
|
|
571
|
+
fetchJson(googleSessionApiPath)
|
|
572
|
+
.then((payload) => {
|
|
573
|
+
if (cancelled) return;
|
|
574
|
+
const sessionData = payload?.data || {};
|
|
575
|
+
if (!sessionData?.authenticated || !sessionData?.user?.sub || !sessionData?.owner_jid) {
|
|
576
|
+
setGoogleAuth({ user: null, expiresAt: '' });
|
|
577
|
+
clearGoogleAuthCache();
|
|
578
|
+
redirectToLogin('Sessão inválida ou expirada. Redirecionando para login...');
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const nextAuth = {
|
|
582
|
+
user: {
|
|
583
|
+
sub: String(sessionData.user.sub || ''),
|
|
584
|
+
email: String(sessionData.user.email || ''),
|
|
585
|
+
name: String(sessionData.user.name || 'Conta Google'),
|
|
586
|
+
picture: String(sessionData.user.picture || ''),
|
|
587
|
+
},
|
|
588
|
+
expiresAt: String(sessionData.expires_at || ''),
|
|
589
|
+
};
|
|
590
|
+
setGoogleAuth(nextAuth);
|
|
591
|
+
writeGoogleAuthCache(nextAuth);
|
|
592
|
+
})
|
|
593
|
+
.catch(() => {
|
|
594
|
+
if (cancelled) return;
|
|
595
|
+
setError('Não foi possível validar sua sessão agora. Recarregue a página.');
|
|
596
|
+
})
|
|
597
|
+
.finally(() => {
|
|
598
|
+
if (cancelled) return;
|
|
599
|
+
setGoogleSessionChecked(true);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
return () => {
|
|
603
|
+
cancelled = true;
|
|
604
|
+
};
|
|
605
|
+
}, [googleSessionApiPath, redirectToLogin]);
|
|
606
|
+
|
|
607
|
+
useEffect(() => {
|
|
608
|
+
try {
|
|
609
|
+
const raw = localStorage.getItem(CREATE_PACK_DRAFT_KEY);
|
|
610
|
+
if (!raw) {
|
|
611
|
+
setDraftLoaded(true);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
const parsed = JSON.parse(raw);
|
|
615
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
616
|
+
setDraftLoaded(true);
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
const restoredName = typeof parsed.name === 'string' ? sanitizePackNameInput(parsed.name, DEFAULT_LIMITS.pack_name_max_length) : '';
|
|
621
|
+
if (restoredName) setName(restoredName);
|
|
622
|
+
if (typeof parsed.description === 'string') setDescription(parsed.description);
|
|
623
|
+
if (typeof parsed.publisher === 'string') setPublisher(parsed.publisher);
|
|
624
|
+
if (typeof parsed.visibility === 'string') setVisibility(parsed.visibility);
|
|
625
|
+
if (Array.isArray(parsed.tags)) setTags(mergeTags(parsed.tags).slice(0, MAX_MANUAL_TAGS));
|
|
626
|
+
const parsedStep = Number.isFinite(Number(parsed.step)) ? Math.max(1, Math.min(3, Number(parsed.step))) : 1;
|
|
627
|
+
|
|
628
|
+
let restoredCount = 0;
|
|
629
|
+
if (Array.isArray(parsed.files)) {
|
|
630
|
+
const restored = parsed.files
|
|
631
|
+
.filter((item) => item && typeof item.dataUrl === 'string' && typeof item.name === 'string')
|
|
632
|
+
.map((item) => ({
|
|
633
|
+
id: String(item.id || `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`),
|
|
634
|
+
file: {
|
|
635
|
+
name: String(item.name || 'sticker.webp'),
|
|
636
|
+
size: Number(item.size || 0),
|
|
637
|
+
type: String(item.type || 'image/webp'),
|
|
638
|
+
},
|
|
639
|
+
hash: /^[a-f0-9]{64}$/.test(String(item.hash || '').toLowerCase()) ? String(item.hash || '').toLowerCase() : '',
|
|
640
|
+
mediaKind:
|
|
641
|
+
String(item.type || '')
|
|
642
|
+
.toLowerCase()
|
|
643
|
+
.startsWith('video/') ||
|
|
644
|
+
String(item.name || '')
|
|
645
|
+
.toLowerCase()
|
|
646
|
+
.match(/\.(mp4|webm|mov|m4v)$/i)
|
|
647
|
+
? 'video'
|
|
648
|
+
: 'image',
|
|
649
|
+
dataUrl: String(item.dataUrl),
|
|
650
|
+
}));
|
|
651
|
+
|
|
652
|
+
if (restored.length) {
|
|
653
|
+
restoredCount = restored.length;
|
|
654
|
+
setFiles(restored.slice(0, DEFAULT_LIMITS.stickers_per_pack));
|
|
655
|
+
setUploadMap(
|
|
656
|
+
restored.reduce((acc, item) => {
|
|
657
|
+
acc[item.id] = { status: 'idle', progress: 0, name: item.file.name };
|
|
658
|
+
return acc;
|
|
659
|
+
}, {}),
|
|
660
|
+
);
|
|
661
|
+
const restoredCoverId = String(parsed.coverId || '');
|
|
662
|
+
setCoverId(restored.find((item) => item.id === restoredCoverId)?.id || restored[0].id);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (parsed?.activeSession && typeof parsed.activeSession === 'object') {
|
|
666
|
+
const packKey = String(parsed.activeSession.packKey || '').trim();
|
|
667
|
+
const editToken = String(parsed.activeSession.editToken || '').trim();
|
|
668
|
+
if (packKey && editToken) {
|
|
669
|
+
setActiveSession({
|
|
670
|
+
packKey,
|
|
671
|
+
editToken,
|
|
672
|
+
webUrl: String(parsed.activeSession.webUrl || '').trim() || null,
|
|
673
|
+
created: parsed.activeSession.created && typeof parsed.activeSession.created === 'object' ? parsed.activeSession.created : null,
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
const normalizedStep = restoredCount === 0 ? Math.min(2, parsedStep) : parsedStep;
|
|
679
|
+
setStep(normalizedStep);
|
|
680
|
+
if (restoredCount > 0 || restoredName) {
|
|
681
|
+
setStatus('Rascunho restaurado automaticamente.');
|
|
682
|
+
}
|
|
683
|
+
} catch {
|
|
684
|
+
// ignore invalid drafts
|
|
685
|
+
} finally {
|
|
686
|
+
setDraftLoaded(true);
|
|
687
|
+
}
|
|
688
|
+
}, []);
|
|
689
|
+
|
|
690
|
+
useEffect(() => {
|
|
691
|
+
if (!draftLoaded || busy) return;
|
|
692
|
+
|
|
693
|
+
const serializable = {
|
|
694
|
+
step,
|
|
695
|
+
name,
|
|
696
|
+
description,
|
|
697
|
+
publisher,
|
|
698
|
+
visibility,
|
|
699
|
+
tags,
|
|
700
|
+
coverId,
|
|
701
|
+
activeSession: activeSession?.packKey && activeSession?.editToken ? activeSession : null,
|
|
702
|
+
files: files.map((item) => ({
|
|
703
|
+
id: item.id,
|
|
704
|
+
name: item?.file?.name || 'sticker.webp',
|
|
705
|
+
size: Number(item?.file?.size || 0),
|
|
706
|
+
type: String(item?.file?.type || 'image/webp'),
|
|
707
|
+
hash: String(item?.hash || ''),
|
|
708
|
+
dataUrl: item.dataUrl,
|
|
709
|
+
})),
|
|
710
|
+
updatedAt: Date.now(),
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
try {
|
|
714
|
+
const raw = JSON.stringify(serializable);
|
|
715
|
+
if (raw.length <= CREATE_PACK_DRAFT_MAX_CHARS) {
|
|
716
|
+
localStorage.setItem(CREATE_PACK_DRAFT_KEY, raw);
|
|
717
|
+
} else {
|
|
718
|
+
const lighter = {
|
|
719
|
+
...serializable,
|
|
720
|
+
step: Math.min(2, Number(serializable.step || 1)),
|
|
721
|
+
coverId: '',
|
|
722
|
+
files: [],
|
|
723
|
+
};
|
|
724
|
+
localStorage.setItem(CREATE_PACK_DRAFT_KEY, JSON.stringify(lighter));
|
|
725
|
+
}
|
|
726
|
+
} catch {
|
|
727
|
+
// ignore storage errors
|
|
728
|
+
}
|
|
729
|
+
}, [draftLoaded, busy, step, name, description, publisher, visibility, tags, coverId, files, activeSession]);
|
|
730
|
+
|
|
731
|
+
useEffect(() => {
|
|
732
|
+
if (!draftLoaded) return;
|
|
733
|
+
if (files.length > 0) return;
|
|
734
|
+
try {
|
|
735
|
+
const rawTask = localStorage.getItem(PACK_UPLOAD_TASK_KEY);
|
|
736
|
+
if (!rawTask) return;
|
|
737
|
+
const task = JSON.parse(rawTask);
|
|
738
|
+
const statusValue = String(task?.status || '').toLowerCase();
|
|
739
|
+
if (statusValue !== 'paused') return;
|
|
740
|
+
localStorage.removeItem(PACK_UPLOAD_TASK_KEY);
|
|
741
|
+
setStatus((prev) => prev || 'Envio pausado anterior foi limpo. Selecione os stickers novamente para continuar.');
|
|
742
|
+
} catch {
|
|
743
|
+
// ignore
|
|
744
|
+
}
|
|
745
|
+
}, [draftLoaded, files.length]);
|
|
746
|
+
|
|
747
|
+
useEffect(() => {
|
|
748
|
+
if (!draftLoaded || busy) return;
|
|
749
|
+
if (!activeSession?.packKey || !activeSession?.editToken) return;
|
|
750
|
+
|
|
751
|
+
let cancelled = false;
|
|
752
|
+
const syncBackendPublishState = async () => {
|
|
753
|
+
try {
|
|
754
|
+
const response = await fetch(`${apiBasePath}/${encodeURIComponent(activeSession.packKey)}/publish-state`, {
|
|
755
|
+
method: 'POST',
|
|
756
|
+
credentials: 'include',
|
|
757
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
758
|
+
body: JSON.stringify({ edit_token: activeSession.editToken }),
|
|
759
|
+
});
|
|
760
|
+
const payload = await response.json().catch(() => ({}));
|
|
761
|
+
if (!response.ok || cancelled) return;
|
|
762
|
+
|
|
763
|
+
const publishState = payload?.data || null;
|
|
764
|
+
const packData = payload?.pack || null;
|
|
765
|
+
if (!publishState || typeof publishState !== 'object') return;
|
|
766
|
+
|
|
767
|
+
setBackendPublishState(publishState);
|
|
768
|
+
if (packData && packData.pack_key) {
|
|
769
|
+
setResult((prev) => (prev?.pack_key === packData.pack_key ? { ...prev, ...packData } : prev || packData));
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const uploads = Array.isArray(publishState.uploads) ? publishState.uploads : [];
|
|
773
|
+
const uploadsById = new Map(uploads.map((entry) => [String(entry.upload_id || ''), entry]));
|
|
774
|
+
const uploadsByHash = new Map(uploads.filter((entry) => entry?.sticker_hash).map((entry) => [String(entry.sticker_hash || ''), entry]));
|
|
775
|
+
|
|
776
|
+
setUploadMap((prev) => {
|
|
777
|
+
const next = { ...prev };
|
|
778
|
+
for (const item of files) {
|
|
779
|
+
const match = uploadsById.get(String(item.id || '')) || (item.hash ? uploadsByHash.get(String(item.hash || '')) : null);
|
|
780
|
+
if (!match) continue;
|
|
781
|
+
const remoteStatus = String(match.status || '').toLowerCase();
|
|
782
|
+
if (remoteStatus === 'done') {
|
|
783
|
+
next[item.id] = {
|
|
784
|
+
...(next[item.id] || {}),
|
|
785
|
+
status: 'done',
|
|
786
|
+
progress: 100,
|
|
787
|
+
error: '',
|
|
788
|
+
};
|
|
789
|
+
} else if (remoteStatus === 'failed') {
|
|
790
|
+
next[item.id] = {
|
|
791
|
+
...(next[item.id] || {}),
|
|
792
|
+
status: 'error',
|
|
793
|
+
progress: 0,
|
|
794
|
+
error: String(match.error_message || 'Falha anterior no upload.'),
|
|
795
|
+
};
|
|
796
|
+
} else if (remoteStatus === 'processing') {
|
|
797
|
+
next[item.id] = {
|
|
798
|
+
...(next[item.id] || {}),
|
|
799
|
+
status: 'uploading',
|
|
800
|
+
progress: Number(next[item.id]?.progress || 0),
|
|
801
|
+
error: '',
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
return next;
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
const doneCount = files.reduce((acc, item) => {
|
|
809
|
+
const match = uploadsById.get(String(item.id || '')) || (item.hash ? uploadsByHash.get(String(item.hash || '')) : null);
|
|
810
|
+
return acc + (String(match?.status || '').toLowerCase() === 'done' ? 1 : 0);
|
|
811
|
+
}, 0);
|
|
812
|
+
|
|
813
|
+
setProgress({
|
|
814
|
+
current: doneCount,
|
|
815
|
+
total: Math.max(files.length, Number(publishState?.consistency?.total_uploads || 0), files.length ? 0 : 0),
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
const realStatus = String(publishState.status || '').toLowerCase();
|
|
819
|
+
if (realStatus === PACK_STATUS_PUBLISHED) {
|
|
820
|
+
clearCreatePackStorage();
|
|
821
|
+
setActiveSession(null);
|
|
822
|
+
setPublishPhase('idle');
|
|
823
|
+
setError('');
|
|
824
|
+
setStatus('Pack já foi publicado no backend. Rascunho local limpo.');
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
if (realStatus === 'failed') {
|
|
829
|
+
setStatus('Pack com falha no backend. Use "Reparar pack" para retomar o fluxo.');
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
if (realStatus === 'processing') {
|
|
834
|
+
setStatus('Pack em processamento/finalização. Você pode tentar publicar novamente para concluir.');
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
if (realStatus === 'draft' || realStatus === 'uploading') {
|
|
839
|
+
setStatus('Rascunho sincronizado com o backend. Você pode retomar o envio com segurança.');
|
|
840
|
+
}
|
|
841
|
+
} catch {
|
|
842
|
+
// mantém estado local se backend estiver indisponível
|
|
843
|
+
}
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
syncBackendPublishState();
|
|
847
|
+
return () => {
|
|
848
|
+
cancelled = true;
|
|
849
|
+
};
|
|
850
|
+
}, [draftLoaded, busy, activeSession, apiBasePath, files]);
|
|
851
|
+
|
|
852
|
+
const addIncomingFiles = async (incoming) => {
|
|
853
|
+
const raw = Array.from(incoming || []).filter(Boolean);
|
|
854
|
+
if (!raw.length) return;
|
|
855
|
+
|
|
856
|
+
const filtered = raw.filter((file) => {
|
|
857
|
+
const lowerName = String(file.name || '').toLowerCase();
|
|
858
|
+
const lowerType = String(file.type || '').toLowerCase();
|
|
859
|
+
const isImage = lowerType.startsWith('image/');
|
|
860
|
+
const isVideo = lowerType.startsWith('video/') || lowerName.endsWith('.mp4') || lowerName.endsWith('.webm') || lowerName.endsWith('.mov') || lowerName.endsWith('.m4v');
|
|
861
|
+
if (!isImage && !isVideo) return false;
|
|
862
|
+
const maxBytes = Number(limits.sticker_upload_source_max_bytes || 0);
|
|
863
|
+
return Number(file.size || 0) <= maxBytes;
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
if (!filtered.length) {
|
|
867
|
+
setError(`Envie imagem ou vídeo até ${toBytesLabel(limits.sticker_upload_source_max_bytes)}. O sistema converte automaticamente para webp.`);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const availableSlots = Math.max(0, Number(limits.stickers_per_pack || 30) - files.length);
|
|
872
|
+
const selected = filtered.slice(0, availableSlots);
|
|
873
|
+
if (!selected.length) {
|
|
874
|
+
setError(`Seu pack pode ter até ${limits.stickers_per_pack} stickers.`);
|
|
875
|
+
return;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
setError('');
|
|
879
|
+
const mapped = await Promise.all(
|
|
880
|
+
selected.map(async (file) => {
|
|
881
|
+
const dataUrl = await fileToDataUrl(file);
|
|
882
|
+
return {
|
|
883
|
+
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
|
884
|
+
file,
|
|
885
|
+
hash: await computeDataUrlSha256(dataUrl),
|
|
886
|
+
mediaKind:
|
|
887
|
+
String(file.type || '')
|
|
888
|
+
.toLowerCase()
|
|
889
|
+
.startsWith('video/') ||
|
|
890
|
+
String(file.name || '')
|
|
891
|
+
.toLowerCase()
|
|
892
|
+
.match(/\.(mp4|webm|mov|m4v)$/i)
|
|
893
|
+
? 'video'
|
|
894
|
+
: 'image',
|
|
895
|
+
dataUrl,
|
|
896
|
+
};
|
|
897
|
+
}),
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
setFiles((prev) => [...prev, ...mapped].slice(0, limits.stickers_per_pack));
|
|
901
|
+
setUploadMap((prev) => {
|
|
902
|
+
const next = { ...prev };
|
|
903
|
+
for (const item of mapped) {
|
|
904
|
+
next[item.id] = { status: 'idle', progress: 0, name: item.file.name };
|
|
905
|
+
}
|
|
906
|
+
return next;
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
if (!coverId && mapped[0]?.id) {
|
|
910
|
+
setCoverId(mapped[0].id);
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
const onDropUpload = async (event) => {
|
|
915
|
+
event.preventDefault();
|
|
916
|
+
setDragActive(false);
|
|
917
|
+
await addIncomingFiles(event.dataTransfer?.files || []);
|
|
918
|
+
};
|
|
919
|
+
|
|
920
|
+
const removeSticker = (id) => {
|
|
921
|
+
setFiles((prev) => {
|
|
922
|
+
const next = prev.filter((item) => item.id !== id);
|
|
923
|
+
if (id === coverId) {
|
|
924
|
+
setCoverId(next[0]?.id || '');
|
|
925
|
+
}
|
|
926
|
+
return next;
|
|
927
|
+
});
|
|
928
|
+
setUploadMap((prev) => {
|
|
929
|
+
const next = { ...prev };
|
|
930
|
+
delete next[id];
|
|
931
|
+
return next;
|
|
932
|
+
});
|
|
933
|
+
};
|
|
934
|
+
|
|
935
|
+
const reorderStickers = (fromId, toId) => {
|
|
936
|
+
if (!fromId || !toId || fromId === toId) return;
|
|
937
|
+
setFiles((prev) => {
|
|
938
|
+
const fromIndex = prev.findIndex((item) => item.id === fromId);
|
|
939
|
+
const toIndex = prev.findIndex((item) => item.id === toId);
|
|
940
|
+
if (fromIndex < 0 || toIndex < 0) return prev;
|
|
941
|
+
const clone = [...prev];
|
|
942
|
+
const [moved] = clone.splice(fromIndex, 1);
|
|
943
|
+
clone.splice(toIndex, 0, moved);
|
|
944
|
+
return clone;
|
|
945
|
+
});
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
const publishPack = async () => {
|
|
949
|
+
const finalName = sanitizePackName(name, limits.pack_name_max_length);
|
|
950
|
+
const finalPublisher = clampText(publisher || 'OmniZap Creator', limits.publisher_max_length);
|
|
951
|
+
const finalDescription = clampText(description, limits.description_max_length);
|
|
952
|
+
|
|
953
|
+
if (!finalName) {
|
|
954
|
+
setError('Informe um nome válido para o pack.');
|
|
955
|
+
setStep(1);
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
if (!hasGoogleLogin) {
|
|
959
|
+
redirectToLogin('Sua sessão expirou. Redirecionando para login...');
|
|
960
|
+
setStep(1);
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
if (!files.length) {
|
|
964
|
+
setError('Adicione ao menos 1 sticker para publicar.');
|
|
965
|
+
setStep(2);
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
setBusy(true);
|
|
970
|
+
setError('');
|
|
971
|
+
setBackendPublishState((prev) => prev || null);
|
|
972
|
+
const doneBeforeRun = files.reduce((acc, item) => (uploadMap[item.id]?.status === 'done' ? acc + 1 : acc), 0);
|
|
973
|
+
setProgress({ current: doneBeforeRun, total: files.length });
|
|
974
|
+
let session = activeSession;
|
|
975
|
+
|
|
976
|
+
try {
|
|
977
|
+
if (!session?.packKey || !session?.editToken) {
|
|
978
|
+
setPublishPhase('creating');
|
|
979
|
+
setStatus('Criando pack...');
|
|
980
|
+
writeUploadTask({
|
|
981
|
+
status: 'running',
|
|
982
|
+
title: 'Publicando pack',
|
|
983
|
+
phase: 'creating',
|
|
984
|
+
current: doneBeforeRun,
|
|
985
|
+
total: files.length,
|
|
986
|
+
progress: Math.round((doneBeforeRun / Math.max(1, files.length)) * 100),
|
|
987
|
+
packKey: null,
|
|
988
|
+
packUrl: null,
|
|
989
|
+
message: 'Criando pack...',
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
const createResponse = await fetch(`${apiBasePath}/create`, {
|
|
993
|
+
method: 'POST',
|
|
994
|
+
credentials: 'include',
|
|
995
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
996
|
+
body: JSON.stringify({
|
|
997
|
+
name: finalName,
|
|
998
|
+
publisher: finalPublisher,
|
|
999
|
+
description: finalDescription,
|
|
1000
|
+
tags,
|
|
1001
|
+
visibility,
|
|
1002
|
+
}),
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
const createPayload = await createResponse.json().catch(() => ({}));
|
|
1006
|
+
if (createResponse.status === 401 || createResponse.status === 403) {
|
|
1007
|
+
redirectToLogin('Sua sessão expirou. Redirecionando para login...');
|
|
1008
|
+
}
|
|
1009
|
+
if (!createResponse.ok) throw new Error(createPayload?.error || 'Não foi possível criar o pack.');
|
|
1010
|
+
|
|
1011
|
+
const created = createPayload?.data || {};
|
|
1012
|
+
const editToken = String(createPayload?.meta?.edit_token || '').trim();
|
|
1013
|
+
const packKey = String(created?.pack_key || '').trim();
|
|
1014
|
+
if (!editToken || !packKey) throw new Error('Resposta de criação inválida.');
|
|
1015
|
+
session = {
|
|
1016
|
+
packKey,
|
|
1017
|
+
editToken,
|
|
1018
|
+
webUrl: created?.web_url || `${webPath}/${packKey}`,
|
|
1019
|
+
created,
|
|
1020
|
+
};
|
|
1021
|
+
setActiveSession(session);
|
|
1022
|
+
setResult(created);
|
|
1023
|
+
setBackendPublishState((prev) => ({
|
|
1024
|
+
...(prev || {}),
|
|
1025
|
+
pack_key: packKey,
|
|
1026
|
+
status: String(created?.status || 'draft').toLowerCase(),
|
|
1027
|
+
}));
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
const pendingFiles = files.filter((item) => uploadMap[item.id]?.status !== 'done');
|
|
1031
|
+
let processed = doneBeforeRun;
|
|
1032
|
+
let failedCount = 0;
|
|
1033
|
+
|
|
1034
|
+
if (pendingFiles.length > 0) {
|
|
1035
|
+
setPublishPhase('uploading');
|
|
1036
|
+
setStatus('Enviando stickers...');
|
|
1037
|
+
writeUploadTask({
|
|
1038
|
+
status: 'running',
|
|
1039
|
+
title: 'Publicando pack',
|
|
1040
|
+
phase: 'uploading',
|
|
1041
|
+
current: doneBeforeRun,
|
|
1042
|
+
total: files.length,
|
|
1043
|
+
progress: Math.round((doneBeforeRun / Math.max(1, files.length)) * 100),
|
|
1044
|
+
packKey: session.packKey,
|
|
1045
|
+
packUrl: session.webUrl,
|
|
1046
|
+
message: 'Enviando stickers...',
|
|
1047
|
+
});
|
|
1048
|
+
setUploadMap((prev) => {
|
|
1049
|
+
const next = { ...prev };
|
|
1050
|
+
for (const item of pendingFiles) {
|
|
1051
|
+
next[item.id] = {
|
|
1052
|
+
...(next[item.id] || {}),
|
|
1053
|
+
status: 'uploading',
|
|
1054
|
+
progress: 0,
|
|
1055
|
+
error: '',
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
return next;
|
|
1059
|
+
});
|
|
1060
|
+
|
|
1061
|
+
await runAsyncQueue(
|
|
1062
|
+
pendingFiles,
|
|
1063
|
+
async (item) => {
|
|
1064
|
+
let effectiveHash = String(item.hash || '');
|
|
1065
|
+
if (!effectiveHash) {
|
|
1066
|
+
effectiveHash = await computeDataUrlSha256(item.dataUrl);
|
|
1067
|
+
if (effectiveHash) {
|
|
1068
|
+
setFiles((prev) => prev.map((entry) => (entry.id === item.id ? { ...entry, hash: effectiveHash } : entry)));
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
const effectiveItem = effectiveHash && effectiveHash !== item.hash ? { ...item, hash: effectiveHash } : item;
|
|
1073
|
+
|
|
1074
|
+
try {
|
|
1075
|
+
const uploadPayload = await uploadStickerWithRetry({
|
|
1076
|
+
apiBasePath,
|
|
1077
|
+
packKey: session.packKey,
|
|
1078
|
+
editToken: session.editToken,
|
|
1079
|
+
item: effectiveItem,
|
|
1080
|
+
setCover: effectiveItem.id === coverId,
|
|
1081
|
+
onProgress: (percentage) => {
|
|
1082
|
+
setUploadMap((prev) => ({
|
|
1083
|
+
...prev,
|
|
1084
|
+
[effectiveItem.id]: {
|
|
1085
|
+
...(prev[effectiveItem.id] || {}),
|
|
1086
|
+
status: 'uploading',
|
|
1087
|
+
progress: percentage,
|
|
1088
|
+
error: '',
|
|
1089
|
+
},
|
|
1090
|
+
}));
|
|
1091
|
+
writeUploadTask({
|
|
1092
|
+
status: 'running',
|
|
1093
|
+
title: 'Publicando pack',
|
|
1094
|
+
phase: 'uploading',
|
|
1095
|
+
current: processed,
|
|
1096
|
+
total: files.length,
|
|
1097
|
+
progress: Math.round(((processed + percentage / 100) / Math.max(1, files.length)) * 100),
|
|
1098
|
+
packKey: session.packKey,
|
|
1099
|
+
packUrl: session.webUrl,
|
|
1100
|
+
message: `Enviando ${effectiveItem.file.name}`,
|
|
1101
|
+
});
|
|
1102
|
+
},
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
setUploadMap((prev) => ({
|
|
1106
|
+
...prev,
|
|
1107
|
+
[effectiveItem.id]: {
|
|
1108
|
+
...(prev[effectiveItem.id] || {}),
|
|
1109
|
+
status: 'done',
|
|
1110
|
+
progress: 100,
|
|
1111
|
+
error: '',
|
|
1112
|
+
},
|
|
1113
|
+
}));
|
|
1114
|
+
|
|
1115
|
+
const remotePackStatus = String(uploadPayload?.data?.pack_status || '').toLowerCase();
|
|
1116
|
+
if (remotePackStatus) {
|
|
1117
|
+
setBackendPublishState((prev) => ({
|
|
1118
|
+
...(prev || {}),
|
|
1119
|
+
pack_key: session.packKey,
|
|
1120
|
+
status: remotePackStatus,
|
|
1121
|
+
}));
|
|
1122
|
+
}
|
|
1123
|
+
} catch (err) {
|
|
1124
|
+
failedCount += 1;
|
|
1125
|
+
setUploadMap((prev) => ({
|
|
1126
|
+
...prev,
|
|
1127
|
+
[effectiveItem.id]: {
|
|
1128
|
+
...(prev[effectiveItem.id] || {}),
|
|
1129
|
+
status: 'error',
|
|
1130
|
+
progress: 0,
|
|
1131
|
+
error: err?.message || 'Falha de upload',
|
|
1132
|
+
},
|
|
1133
|
+
}));
|
|
1134
|
+
} finally {
|
|
1135
|
+
processed += 1;
|
|
1136
|
+
setProgress({ current: processed, total: files.length });
|
|
1137
|
+
writeUploadTask({
|
|
1138
|
+
status: 'running',
|
|
1139
|
+
title: 'Publicando pack',
|
|
1140
|
+
phase: 'uploading',
|
|
1141
|
+
current: processed,
|
|
1142
|
+
total: files.length,
|
|
1143
|
+
progress: Math.round((processed / Math.max(1, files.length)) * 100),
|
|
1144
|
+
packKey: session.packKey,
|
|
1145
|
+
packUrl: session.webUrl,
|
|
1146
|
+
message: processed >= files.length ? 'Preparando finalização...' : 'Processando próximo sticker...',
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
},
|
|
1150
|
+
FIXED_UPLOAD_QUEUE_CONCURRENCY,
|
|
1151
|
+
);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
if (failedCount > 0) {
|
|
1155
|
+
setPublishPhase('idle');
|
|
1156
|
+
setStatus(`Upload concluído com ${failedCount} falha(s).`);
|
|
1157
|
+
setError(`Alguns stickers falharam. Clique em "🚀 Publicar Pack" novamente para reenviar apenas as falhas.`);
|
|
1158
|
+
setResult((prev) => prev || session.created || null);
|
|
1159
|
+
setBackendPublishState((prev) => ({
|
|
1160
|
+
...(prev || {}),
|
|
1161
|
+
pack_key: session.packKey,
|
|
1162
|
+
status: 'draft',
|
|
1163
|
+
}));
|
|
1164
|
+
setStep(3);
|
|
1165
|
+
writeUploadTask({
|
|
1166
|
+
status: 'error',
|
|
1167
|
+
title: 'Publicação parcial',
|
|
1168
|
+
phase: 'uploading',
|
|
1169
|
+
current: Number(processed || 0),
|
|
1170
|
+
total: Number(files.length || 0),
|
|
1171
|
+
progress: Math.round((Number(processed || 0) / Math.max(1, Number(files.length || 1))) * 100),
|
|
1172
|
+
packKey: session.packKey,
|
|
1173
|
+
packUrl: session.webUrl,
|
|
1174
|
+
message: `${failedCount} sticker(s) falharam no upload.`,
|
|
1175
|
+
});
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
setPublishPhase('processing');
|
|
1180
|
+
setStatus('Processando stickers...');
|
|
1181
|
+
writeUploadTask({
|
|
1182
|
+
status: 'running',
|
|
1183
|
+
title: 'Publicando pack',
|
|
1184
|
+
phase: 'processing',
|
|
1185
|
+
current: Number(files.length || 0),
|
|
1186
|
+
total: Number(files.length || 0),
|
|
1187
|
+
progress: 100,
|
|
1188
|
+
packKey: session.packKey,
|
|
1189
|
+
packUrl: session.webUrl,
|
|
1190
|
+
message: 'Validando consistência do pack...',
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
setPublishPhase('publishing');
|
|
1194
|
+
setStatus('Publicando pack...');
|
|
1195
|
+
const finalizeResponse = await fetch(`${apiBasePath}/${encodeURIComponent(session.packKey)}/finalize`, {
|
|
1196
|
+
method: 'POST',
|
|
1197
|
+
credentials: 'include',
|
|
1198
|
+
headers: { 'Content-Type': 'application/json; charset=utf-8' },
|
|
1199
|
+
body: JSON.stringify({ edit_token: session.editToken }),
|
|
1200
|
+
});
|
|
1201
|
+
const finalizePayload = await finalizeResponse.json().catch(() => ({}));
|
|
1202
|
+
if (finalizeResponse.status === 409) {
|
|
1203
|
+
const publishState = finalizePayload?.data?.publish_state || null;
|
|
1204
|
+
const packFromFinalize = finalizePayload?.data?.pack || session.created || null;
|
|
1205
|
+
if (publishState) setBackendPublishState(publishState);
|
|
1206
|
+
if (packFromFinalize) setResult(packFromFinalize);
|
|
1207
|
+
setPublishPhase('idle');
|
|
1208
|
+
setStatus('Pack ficou em rascunho aguardando correções.');
|
|
1209
|
+
setError(finalizePayload?.error || 'Finalize recusado: o pack ainda não está consistente.');
|
|
1210
|
+
writeUploadTask({
|
|
1211
|
+
status: 'error',
|
|
1212
|
+
title: 'Finalize pendente',
|
|
1213
|
+
phase: 'finalize',
|
|
1214
|
+
current: Number(files.length || 0),
|
|
1215
|
+
total: Number(files.length || 0),
|
|
1216
|
+
progress: 100,
|
|
1217
|
+
packKey: session.packKey,
|
|
1218
|
+
packUrl: session.webUrl,
|
|
1219
|
+
message: finalizePayload?.error || 'Pack ainda não pode ser publicado.',
|
|
1220
|
+
});
|
|
1221
|
+
setStep(3);
|
|
1222
|
+
return;
|
|
1223
|
+
}
|
|
1224
|
+
if (!finalizeResponse.ok) {
|
|
1225
|
+
throw new Error(finalizePayload?.error || 'Falha ao finalizar publicação do pack.');
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
const finalizeData = finalizePayload?.data || {};
|
|
1229
|
+
const publishedPack = finalizeData?.pack || session.created || result;
|
|
1230
|
+
const publishState = finalizeData?.publish_state || null;
|
|
1231
|
+
if (publishState) setBackendPublishState(publishState);
|
|
1232
|
+
setStatus('Pack publicado com sucesso.');
|
|
1233
|
+
setResult(publishedPack);
|
|
1234
|
+
setStep(3);
|
|
1235
|
+
setPublishPhase('idle');
|
|
1236
|
+
setActiveSession(null);
|
|
1237
|
+
clearCreatePackStorage();
|
|
1238
|
+
writeUploadTask({
|
|
1239
|
+
status: 'done',
|
|
1240
|
+
title: 'Pack publicado',
|
|
1241
|
+
phase: 'published',
|
|
1242
|
+
current: Number(files.length || 0),
|
|
1243
|
+
total: Number(files.length || 0),
|
|
1244
|
+
progress: 100,
|
|
1245
|
+
packKey: session.packKey,
|
|
1246
|
+
packUrl: session.webUrl,
|
|
1247
|
+
message: 'Pack publicado com sucesso.',
|
|
1248
|
+
});
|
|
1249
|
+
} catch (err) {
|
|
1250
|
+
setPublishPhase('idle');
|
|
1251
|
+
setError(err?.message || 'Falha ao publicar pack.');
|
|
1252
|
+
setStatus('');
|
|
1253
|
+
writeUploadTask({
|
|
1254
|
+
status: 'error',
|
|
1255
|
+
title: 'Falha na publicação',
|
|
1256
|
+
phase: publishPhase || 'unknown',
|
|
1257
|
+
current: Number(progress.current || 0),
|
|
1258
|
+
total: Number(progress.total || files.length || 0),
|
|
1259
|
+
progress: Math.round((Number(progress.current || 0) / Math.max(1, Number(progress.total || files.length || 1))) * 100),
|
|
1260
|
+
packKey: session?.packKey || activeSession?.packKey || result?.pack_key || null,
|
|
1261
|
+
packUrl: session?.webUrl || activeSession?.webUrl || result?.web_url || null,
|
|
1262
|
+
message: err?.message || 'Falha ao publicar pack.',
|
|
1263
|
+
});
|
|
1264
|
+
} finally {
|
|
1265
|
+
setBusy(false);
|
|
1266
|
+
}
|
|
1267
|
+
};
|
|
1268
|
+
|
|
1269
|
+
useEffect(() => {
|
|
1270
|
+
const onBeforeUnload = () => {
|
|
1271
|
+
if (!busy) return;
|
|
1272
|
+
writeUploadTask({
|
|
1273
|
+
status: 'paused',
|
|
1274
|
+
title: 'Publicação pausada',
|
|
1275
|
+
current: Number(progress.current || 0),
|
|
1276
|
+
total: Number(progress.total || files.length || 0),
|
|
1277
|
+
progress: Math.round((Number(progress.current || 0) / Math.max(1, Number(progress.total || files.length || 1))) * 100),
|
|
1278
|
+
packKey: activeSession?.packKey || result?.pack_key || null,
|
|
1279
|
+
packUrl: activeSession?.webUrl || result?.web_url || null,
|
|
1280
|
+
message: 'Você saiu da tela durante o envio.',
|
|
1281
|
+
});
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
window.addEventListener('beforeunload', onBeforeUnload);
|
|
1285
|
+
return () => window.removeEventListener('beforeunload', onBeforeUnload);
|
|
1286
|
+
}, [busy, progress.current, progress.total, files.length, result, activeSession]);
|
|
1287
|
+
|
|
1288
|
+
const nextStep = () => {
|
|
1289
|
+
if (step === 1 && !canStep2) {
|
|
1290
|
+
if (!sanitizePackName(name, limits.pack_name_max_length).length) {
|
|
1291
|
+
setError('Defina um nome para avançar.');
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
if (!googleSessionChecked) {
|
|
1295
|
+
setError('Validando sua sessão. Aguarde alguns segundos...');
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
redirectToLogin('Sessão não encontrada. Redirecionando para login...');
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
if (step === 2 && !canStep3) {
|
|
1302
|
+
setError('Adicione stickers para avançar.');
|
|
1303
|
+
return;
|
|
1304
|
+
}
|
|
1305
|
+
setError('');
|
|
1306
|
+
setStep((prev) => Math.min(3, prev + 1));
|
|
1307
|
+
};
|
|
1308
|
+
|
|
1309
|
+
const prevStep = () => {
|
|
1310
|
+
setError('');
|
|
1311
|
+
setStep((prev) => Math.max(1, prev - 1));
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
const restartCreateFlow = () => {
|
|
1315
|
+
if (busy) return;
|
|
1316
|
+
const confirmed = window.confirm('Recomeçar a criação? Isso vai limpar o rascunho local e o progresso salvo neste dispositivo.');
|
|
1317
|
+
if (!confirmed) return;
|
|
1318
|
+
|
|
1319
|
+
clearCreatePackStorage();
|
|
1320
|
+
setStep(1);
|
|
1321
|
+
setName('');
|
|
1322
|
+
setDescription('');
|
|
1323
|
+
setPublisher('');
|
|
1324
|
+
setVisibility('public');
|
|
1325
|
+
setTags([]);
|
|
1326
|
+
setTagInput('');
|
|
1327
|
+
setFiles([]);
|
|
1328
|
+
setCoverId('');
|
|
1329
|
+
setDragActive(false);
|
|
1330
|
+
setDraggingStickerId('');
|
|
1331
|
+
setStatus('Criação reiniciada. Dados locais foram limpos.');
|
|
1332
|
+
setError('');
|
|
1333
|
+
setPublishPhase('idle');
|
|
1334
|
+
setProgress({ current: 0, total: 0 });
|
|
1335
|
+
setUploadMap({});
|
|
1336
|
+
setActiveSession(null);
|
|
1337
|
+
setResult(null);
|
|
1338
|
+
setBackendPublishState(null);
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
const addTag = (rawValue) => {
|
|
1342
|
+
const normalized = normalizeTag(rawValue);
|
|
1343
|
+
if (!normalized) return;
|
|
1344
|
+
setTags((prev) => {
|
|
1345
|
+
if (prev.includes(normalized) || prev.length >= MAX_MANUAL_TAGS) return prev;
|
|
1346
|
+
return [...prev, normalized];
|
|
1347
|
+
});
|
|
1348
|
+
setTagInput('');
|
|
1349
|
+
};
|
|
1350
|
+
|
|
1351
|
+
const removeTag = (value) => {
|
|
1352
|
+
const normalized = normalizeTag(value);
|
|
1353
|
+
setTags((prev) => prev.filter((entry) => entry !== normalized));
|
|
1354
|
+
};
|
|
1355
|
+
|
|
1356
|
+
const onTagInputKeyDown = (event) => {
|
|
1357
|
+
if (event.key === 'Tab' && !event.shiftKey && tagInput.trim() && tagTypeaheadSuggestions.length) {
|
|
1358
|
+
event.preventDefault();
|
|
1359
|
+
addTag(tagTypeaheadSuggestions[0]);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
if (event.key === 'Enter' || event.key === ',') {
|
|
1363
|
+
event.preventDefault();
|
|
1364
|
+
addTag(tagInput);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
if (event.key === 'Backspace' && !tagInput.trim()) {
|
|
1368
|
+
const last = tags[tags.length - 1];
|
|
1369
|
+
if (last) removeTag(last);
|
|
1370
|
+
}
|
|
1371
|
+
};
|
|
1372
|
+
|
|
1373
|
+
const visibilityHelp = visibility === 'private' ? 'Privado: apenas você acessa este pack.' : visibility === 'unlisted' ? 'Não listado: acesso por link direto.' : 'Público: aparece no catálogo para descoberta.';
|
|
1374
|
+
|
|
1375
|
+
const uploadProgressTotal = Math.max(0, Number(progress.total || files.length || 0));
|
|
1376
|
+
const uploadProgressDone = Math.max(0, Math.min(uploadProgressTotal || 0, Number(progress.current || 0)));
|
|
1377
|
+
const uploadProgressPercent = Math.max(0, Math.min(100, Math.round((uploadProgressDone / Math.max(1, uploadProgressTotal || 1)) * 100)));
|
|
1378
|
+
const uploadHasFailures = failedUploadsCount > 0;
|
|
1379
|
+
const backendStateFailed = backendPackStatus === 'failed';
|
|
1380
|
+
const publishCompleted = Boolean(result && String(backendPackStatus || result?.status || '').toLowerCase() === PACK_STATUS_PUBLISHED && !busy);
|
|
1381
|
+
const showUploadProgressCard = step === 3 && busy;
|
|
1382
|
+
const showUploadFailureCard = step === 3 && !busy && (uploadHasFailures || backendStateFailed);
|
|
1383
|
+
const publishedPackUrl = String(result?.web_url || activeSession?.webUrl || '').trim() || (result?.pack_key ? `${webPath}/${encodeURIComponent(String(result.pack_key || ''))}` : '');
|
|
1384
|
+
const finalStepPrimaryLabel = publishCompleted ? '👁 Ver pack criado' : publishLabel;
|
|
1385
|
+
const mobilePrimaryActionLabel = step < 3 ? 'Continuar' : finalStepPrimaryLabel;
|
|
1386
|
+
const mobilePrimaryActionClass = step < 3 ? 'bg-accent text-slate-900' : 'bg-accent2 text-slate-900';
|
|
1387
|
+
const toggleMobilePreview = () => setMobilePreviewOpen((prev) => !prev);
|
|
1388
|
+
const openCreatedPack = () => {
|
|
1389
|
+
if (!publishedPackUrl) return;
|
|
1390
|
+
window.location.assign(publishedPackUrl);
|
|
1391
|
+
};
|
|
1392
|
+
const handleFinalStepPrimaryAction = () => {
|
|
1393
|
+
if (publishCompleted) {
|
|
1394
|
+
openCreatedPack();
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
publishPack();
|
|
1398
|
+
};
|
|
1399
|
+
const finalStepPrimaryDisabled = publishCompleted ? !publishedPackUrl : !publishReady;
|
|
1400
|
+
|
|
1401
|
+
return html`
|
|
1402
|
+
<div className="min-h-screen bg-gradient-to-b from-[#0a0f15] via-[#0d1219] to-[#0e141a]">
|
|
1403
|
+
<div className="mx-auto w-full max-w-7xl px-4 pb-32 pt-4 md:px-6 md:pb-10 md:pt-5">
|
|
1404
|
+
<header className="mb-4 flex flex-wrap items-start justify-between gap-3 md:mb-6 md:items-center">
|
|
1405
|
+
<div>
|
|
1406
|
+
<p className="mb-1 text-xs font-semibold uppercase tracking-[.15em] text-accent">OmniZap Studio</p>
|
|
1407
|
+
<h1 className="font-display text-2xl font-extrabold leading-tight md:text-4xl">Criar novo Pack</h1>
|
|
1408
|
+
<p className="mt-1 text-xs text-slate-400 md:text-sm">Fluxo guiado para montar e publicar seu pack com visual de marketplace.</p>
|
|
1409
|
+
</div>
|
|
1410
|
+
<div className="flex flex-wrap items-center justify-end gap-1.5 text-[11px] font-semibold md:gap-2">
|
|
1411
|
+
<span className="hidden rounded-full border border-line/60 bg-panel/70 px-3 py-1 sm:inline-flex">🧩 Até ${limits.stickers_per_pack} stickers</span>
|
|
1412
|
+
<span className="hidden rounded-full border border-line/60 bg-panel/70 px-3 py-1 sm:inline-flex">📦 Até ${limits.packs_per_owner} packs</span>
|
|
1413
|
+
<span className="hidden rounded-full border border-line/60 bg-panel/70 px-3 py-1 md:inline-flex">✍ ${limits.pack_name_max_length} caracteres no nome</span>
|
|
1414
|
+
<button type="button" onClick=${restartCreateFlow} disabled=${busy} className="h-8 rounded-full border border-line/70 bg-panel/70 px-3 text-[11px] font-semibold text-slate-200 disabled:opacity-60" title="Limpar rascunho local e recomeçar">Recomeçar</button>
|
|
1415
|
+
</div>
|
|
1416
|
+
</header>
|
|
1417
|
+
|
|
1418
|
+
<div className="mb-3 grid grid-cols-3 gap-2 md:mb-5">${STEPS.map((item) => html`<${StepPill} key=${item.id} step=${item} active=${step === item.id} done=${step > item.id} />`)}</div>
|
|
1419
|
+
<div className="mb-4 md:mb-6">
|
|
1420
|
+
<div className="mb-1 flex items-center justify-between text-[11px] font-semibold text-slate-400">
|
|
1421
|
+
<span>Progresso</span>
|
|
1422
|
+
<span>${completionPercentage}%</span>
|
|
1423
|
+
</div>
|
|
1424
|
+
<div className="h-1.5 overflow-hidden rounded-full bg-slate-900/70 md:h-2">
|
|
1425
|
+
<div className="h-full bg-accent transition-all duration-300" style=${{ width: `${completionPercentage}%` }}></div>
|
|
1426
|
+
</div>
|
|
1427
|
+
</div>
|
|
1428
|
+
|
|
1429
|
+
<div className="grid gap-3 lg:grid-cols-[minmax(340px,1.1fr)_minmax(320px,.9fr)] lg:gap-4">
|
|
1430
|
+
<section className="min-w-0 rounded-2xl border border-line/70 bg-panel/85 p-3 shadow-none md:rounded-3xl md:border-line md:bg-panel md:p-5 md:shadow-panel">
|
|
1431
|
+
${step === 1
|
|
1432
|
+
? html`
|
|
1433
|
+
<div className="space-y-3 md:space-y-4">
|
|
1434
|
+
<${FloatingField} label="Nome do pack" value=${name} maxLength=${limits.pack_name_max_length} hint="Use um nome curto e fácil de encontrar." onChange=${(e) => setName(sanitizePackNameInput(e.target.value, limits.pack_name_max_length))} />
|
|
1435
|
+
<${FloatingField} label="Descrição" value=${description} multiline=${true} maxLength=${limits.description_max_length} hint="Explique o tema do pack em uma frase curta" onChange=${(e) => setDescription(clampInputText(e.target.value, limits.description_max_length))} />
|
|
1436
|
+
<${FloatingField} label="Autor" value=${publisher} maxLength=${limits.publisher_max_length} hint="Como seu nome será exibido no catálogo." onChange=${(e) => setPublisher(clampInputText(e.target.value, limits.publisher_max_length))} />
|
|
1437
|
+
<div className="rounded-2xl border border-line/70 bg-panel/70 p-3 md:p-4">
|
|
1438
|
+
<div className="flex items-start justify-between gap-3">
|
|
1439
|
+
<div>
|
|
1440
|
+
<p className="text-xs font-semibold uppercase tracking-[.08em] text-slate-400">Sessão da conta</p>
|
|
1441
|
+
<p className="mt-1 text-sm font-semibold text-slate-100">Conta Google vinculada</p>
|
|
1442
|
+
<p className="mt-1 text-xs text-slate-400">${googleSessionChecked ? 'Sua sessão foi validada para criar packs.' : 'Validando sua sessão para liberar a criação.'}</p>
|
|
1443
|
+
</div>
|
|
1444
|
+
${hasGoogleLogin ? html`<span className="rounded-full border border-emerald-400/40 bg-emerald-400/10 px-2.5 py-1 text-[11px] font-semibold text-emerald-300">Conectado</span>` : html`<span className="rounded-full border border-amber-400/40 bg-amber-400/10 px-2.5 py-1 text-[11px] font-semibold text-amber-300">Validando</span>`}
|
|
1445
|
+
</div>
|
|
1446
|
+
<div className="mt-3 rounded-xl border border-line/70 bg-panelSoft/80 p-2.5 md:p-3">
|
|
1447
|
+
<p className="truncate text-sm font-semibold text-slate-100">${googleAuth.user?.name || 'Conta Google'}</p>
|
|
1448
|
+
<p className="truncate text-xs text-slate-400">${googleAuth.user?.email || 'Sessão será validada automaticamente.'}</p>
|
|
1449
|
+
</div>
|
|
1450
|
+
</div>
|
|
1451
|
+
<label className="block">
|
|
1452
|
+
<span className="mb-2 inline-block text-xs font-semibold text-slate-300">Tags do pack</span>
|
|
1453
|
+
<div className="rounded-2xl border border-line/70 bg-panelSoft/80 px-3 py-3">
|
|
1454
|
+
<div className="mb-2 flex flex-wrap gap-2">
|
|
1455
|
+
${tags.map(
|
|
1456
|
+
(tag) => html`
|
|
1457
|
+
<button key=${tag} type="button" onClick=${() => removeTag(tag)} className="inline-flex items-center gap-1 rounded-full border border-accent/40 bg-accent/10 px-2.5 py-1 text-[11px] font-semibold text-accent" title="Remover tag">
|
|
1458
|
+
#${tag}
|
|
1459
|
+
<span aria-hidden="true">×</span>
|
|
1460
|
+
</button>
|
|
1461
|
+
`,
|
|
1462
|
+
)}
|
|
1463
|
+
</div>
|
|
1464
|
+
<input type="text" value=${tagInput} maxlength=${40} onInput=${(e) => setTagInput(String(e.target.value || ''))} onKeyDown=${onTagInputKeyDown} onBlur=${() => addTag(tagInput)} placeholder=${tags.length >= MAX_MANUAL_TAGS ? `Limite de ${MAX_MANUAL_TAGS} tags` : 'Digite e pressione Enter para adicionar'} disabled=${tags.length >= MAX_MANUAL_TAGS} className="h-11 w-full rounded-xl border border-line/70 bg-panel/80 px-3 text-sm outline-none transition focus:border-accent/60 disabled:opacity-60" />
|
|
1465
|
+
${tagInput.trim() && tags.length < MAX_MANUAL_TAGS && tagTypeaheadSuggestions.length
|
|
1466
|
+
? html`
|
|
1467
|
+
<div className="mt-2 rounded-xl border border-line/70 bg-panel/70 p-2">
|
|
1468
|
+
<div className="mb-1 flex items-center justify-between gap-2">
|
|
1469
|
+
<p className="text-[10px] font-semibold uppercase tracking-[.08em] text-slate-400">Sugestões</p>
|
|
1470
|
+
<p className="text-[10px] text-slate-500">Tab completa a primeira</p>
|
|
1471
|
+
</div>
|
|
1472
|
+
<div className="flex flex-wrap gap-1.5">${tagTypeaheadSuggestions.map((tag) => html` <button key=${`typeahead-${tag}`} type="button" onMouseDown=${(e) => e.preventDefault()} onClick=${() => addTag(tag)} className="rounded-full border border-accent/35 bg-accent/10 px-2 py-1 text-[10px] font-semibold text-accent transition hover:border-accent/60">#${tag}</button> `)}</div>
|
|
1473
|
+
</div>
|
|
1474
|
+
`
|
|
1475
|
+
: null}
|
|
1476
|
+
<p className="mt-2 text-[11px] text-slate-400">${tags.length}/${MAX_MANUAL_TAGS} tags selecionadas.</p>
|
|
1477
|
+
<div className="mt-2 flex flex-wrap gap-1.5">${suggestedFromText.map((tag) => html` <button key=${tag} type="button" onMouseDown=${(e) => e.preventDefault()} onClick=${() => addTag(tag)} className="rounded-full border border-line bg-panel px-2 py-1 text-[10px] font-semibold text-slate-300 transition hover:border-accent/50 hover:text-accent">+ ${tag}</button> `)}</div>
|
|
1478
|
+
</div>
|
|
1479
|
+
</label>
|
|
1480
|
+
<label className="block">
|
|
1481
|
+
<span className="mb-2 inline-block text-xs font-semibold text-slate-300">Visibilidade</span>
|
|
1482
|
+
<select value=${visibility} onChange=${(e) => setVisibility(String(e.target.value || 'public'))} className="h-11 w-full rounded-2xl border border-line/70 bg-panelSoft/80 px-4 text-sm outline-none focus:border-accent/60 md:h-12">
|
|
1483
|
+
<option value="public">Público</option>
|
|
1484
|
+
<option value="unlisted">Não listado</option>
|
|
1485
|
+
<option value="private">Privado</option>
|
|
1486
|
+
</select>
|
|
1487
|
+
<p className="mt-2 text-[11px] text-slate-400">${visibilityHelp}</p>
|
|
1488
|
+
</label>
|
|
1489
|
+
</div>
|
|
1490
|
+
`
|
|
1491
|
+
: null}
|
|
1492
|
+
${step === 2
|
|
1493
|
+
? html`
|
|
1494
|
+
<div className="space-y-3 md:space-y-4">
|
|
1495
|
+
<div
|
|
1496
|
+
onDragOver=${(e) => {
|
|
1497
|
+
e.preventDefault();
|
|
1498
|
+
setDragActive(true);
|
|
1499
|
+
}}
|
|
1500
|
+
onDragLeave=${() => setDragActive(false)}
|
|
1501
|
+
onDrop=${onDropUpload}
|
|
1502
|
+
className=${`rounded-2xl border border-dashed p-4 text-center transition md:rounded-3xl md:border-2 md:p-6 ${dragActive ? 'border-accent bg-accent/10' : 'border-line/70 bg-panelSoft/80'}`}
|
|
1503
|
+
>
|
|
1504
|
+
<p className="text-sm font-bold md:text-base">Arraste e solte seus stickers aqui</p>
|
|
1505
|
+
<p className="mt-1 text-xs text-slate-400">Imagens e vídeos até ${toBytesLabel(limits.sticker_upload_source_max_bytes)} cada (conversão automática para .webp)</p>
|
|
1506
|
+
<input
|
|
1507
|
+
id="webp-upload"
|
|
1508
|
+
type="file"
|
|
1509
|
+
accept="image/*,video/*"
|
|
1510
|
+
multiple
|
|
1511
|
+
className="hidden"
|
|
1512
|
+
onChange=${async (e) => {
|
|
1513
|
+
await addIncomingFiles(e.target.files || []);
|
|
1514
|
+
e.target.value = '';
|
|
1515
|
+
}}
|
|
1516
|
+
/>
|
|
1517
|
+
<label for="webp-upload" className="mt-3 inline-flex h-11 cursor-pointer items-center rounded-xl bg-accent px-4 text-sm font-extrabold text-slate-900">Selecionar stickers</label>
|
|
1518
|
+
</div>
|
|
1519
|
+
|
|
1520
|
+
<div className="flex items-center justify-between text-xs text-slate-400">
|
|
1521
|
+
<span>${files.length}/${limits.stickers_per_pack} selecionados</span>
|
|
1522
|
+
<span>Arraste para reordenar • toque para definir capa</span>
|
|
1523
|
+
</div>
|
|
1524
|
+
|
|
1525
|
+
${files.length ? html` <div className="grid grid-cols-2 gap-2 sm:grid-cols-3 sm:gap-3 lg:grid-cols-4">${files.map((item, index) => html`<${StickerThumb} key=${item.id} item=${item} index=${index} selectedCoverId=${coverId} onSetCover=${setCoverId} onRemove=${removeSticker} onDragStart=${setDraggingStickerId} onDropOn=${(targetId) => reorderStickers(draggingStickerId, targetId)} />`)}</div> ` : html`<p className="rounded-2xl border border-line/70 bg-panelSoft/80 p-3 text-center text-sm text-slate-400 md:p-4">Nenhum sticker selecionado ainda.</p>`}
|
|
1526
|
+
</div>
|
|
1527
|
+
`
|
|
1528
|
+
: null}
|
|
1529
|
+
${step === 3
|
|
1530
|
+
? html`
|
|
1531
|
+
<div className="space-y-3 md:space-y-4">
|
|
1532
|
+
<div className="rounded-2xl border border-line/70 bg-panelSoft/80 p-3 md:p-4">
|
|
1533
|
+
<div className="flex items-start justify-between gap-3">
|
|
1534
|
+
<div>
|
|
1535
|
+
<h3 className="font-display text-base font-bold md:text-lg">Revisão final</h3>
|
|
1536
|
+
<p className="mt-0.5 text-xs text-slate-400">Confira os dados antes de publicar.</p>
|
|
1537
|
+
</div>
|
|
1538
|
+
<span className="rounded-full border border-line/70 bg-panel/60 px-2.5 py-1 text-[11px] font-semibold text-slate-300"> ${files.length} stickers </span>
|
|
1539
|
+
</div>
|
|
1540
|
+
<div className="mt-3 grid gap-1.5 text-sm text-slate-300">
|
|
1541
|
+
<p className="truncate">
|
|
1542
|
+
<span className="text-slate-400">Nome:</span>
|
|
1543
|
+
<strong>${preview.name}</strong>
|
|
1544
|
+
</p>
|
|
1545
|
+
<p>
|
|
1546
|
+
<span className="text-slate-400">Visibilidade:</span>
|
|
1547
|
+
${preview.visibility}
|
|
1548
|
+
</p>
|
|
1549
|
+
<p className="truncate text-xs text-slate-400">Autor: ${preview.publisher}</p>
|
|
1550
|
+
</div>
|
|
1551
|
+
</div>
|
|
1552
|
+
|
|
1553
|
+
${showUploadProgressCard
|
|
1554
|
+
? html`
|
|
1555
|
+
<div className="rounded-2xl border border-accent/25 bg-accent/5 p-3 md:p-4">
|
|
1556
|
+
<div className="flex items-center justify-between gap-3">
|
|
1557
|
+
<p className="text-sm font-semibold text-slate-100">${status || 'Processando publicação...'}</p>
|
|
1558
|
+
<p className="text-xs font-semibold text-accent">${uploadProgressPercent}%</p>
|
|
1559
|
+
</div>
|
|
1560
|
+
<div className="mt-2 h-2 overflow-hidden rounded-full bg-slate-900/70">
|
|
1561
|
+
<div className="h-full bg-accent transition-all" style=${{ width: `${uploadProgressPercent}%` }}></div>
|
|
1562
|
+
</div>
|
|
1563
|
+
<p className="mt-2 text-xs text-slate-400">${publishPhase === 'creating' ? 'Criando pack...' : publishPhase === 'uploading' ? `${uploadProgressDone}/${uploadProgressTotal || files.length || 0} enviados` : publishPhase === 'processing' ? 'Validando consistência e capa do pack...' : publishPhase === 'publishing' ? 'Publicando pack no marketplace...' : `${uploadProgressDone}/${uploadProgressTotal || files.length || 0} concluídos`}</p>
|
|
1564
|
+
</div>
|
|
1565
|
+
`
|
|
1566
|
+
: null}
|
|
1567
|
+
${showUploadFailureCard
|
|
1568
|
+
? html`
|
|
1569
|
+
<div className="rounded-2xl border border-rose-400/25 bg-rose-400/5 p-3 text-sm">
|
|
1570
|
+
<p className="font-semibold text-rose-200">${backendStateFailed ? 'O pack entrou em estado de falha no backend.' : `${failedUploadsCount} sticker(s) falharam no envio.`}</p>
|
|
1571
|
+
<p className="mt-1 text-xs text-rose-200/80">${backendStateFailed ? `Use "${publishLabel}" para reparar e concluir a publicação.` : `Toque em "${publishLabel}" para reenviar apenas as falhas.`}</p>
|
|
1572
|
+
</div>
|
|
1573
|
+
`
|
|
1574
|
+
: null}
|
|
1575
|
+
${publishCompleted
|
|
1576
|
+
? html`
|
|
1577
|
+
<div className="rounded-2xl border border-emerald-400/25 bg-emerald-400/5 p-3 text-sm text-emerald-100 md:p-4">
|
|
1578
|
+
<p className="font-bold">Pack publicado com sucesso</p>
|
|
1579
|
+
<p className="mt-1">${result.name} · ${result.pack_key}</p>
|
|
1580
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
1581
|
+
<a href=${result.web_url || `${webPath}/${result.pack_key}`} className="inline-flex h-10 items-center rounded-lg bg-emerald-300 px-3 text-xs font-bold text-slate-900">Abrir pack</a>
|
|
1582
|
+
<a href=${webPath} className="inline-flex h-10 items-center rounded-lg border border-emerald-300/30 px-3 text-xs font-bold">Voltar ao marketplace</a>
|
|
1583
|
+
</div>
|
|
1584
|
+
</div>
|
|
1585
|
+
`
|
|
1586
|
+
: null}
|
|
1587
|
+
</div>
|
|
1588
|
+
`
|
|
1589
|
+
: null}
|
|
1590
|
+
</section>
|
|
1591
|
+
|
|
1592
|
+
<aside className="hidden min-w-0 rounded-3xl border border-line/70 bg-panel/85 p-4 lg:block lg:p-5">
|
|
1593
|
+
<div className="mb-2 flex items-center justify-between">
|
|
1594
|
+
<p className="text-xs font-semibold uppercase tracking-[.12em] text-accent">Preview em tempo real</p>
|
|
1595
|
+
<span className="text-[11px] font-semibold text-slate-400">Atualiza automaticamente</span>
|
|
1596
|
+
</div>
|
|
1597
|
+
<${PackPreviewPanel} preview=${preview} quality=${quality} compact=${false} />
|
|
1598
|
+
</aside>
|
|
1599
|
+
</div>
|
|
1600
|
+
|
|
1601
|
+
<div className="mt-3 lg:hidden">
|
|
1602
|
+
<div className="rounded-2xl border border-line/70 bg-panel/80 p-3">
|
|
1603
|
+
<button type="button" onClick=${toggleMobilePreview} className="flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-line/70 bg-panelSoft/70 px-3 text-left" aria-expanded=${mobilePreviewOpen ? 'true' : 'false'}>
|
|
1604
|
+
<div>
|
|
1605
|
+
<p className="text-xs font-semibold uppercase tracking-[.08em] text-slate-400">Preview</p>
|
|
1606
|
+
<p className="text-sm font-semibold text-slate-100">${preview.name}</p>
|
|
1607
|
+
</div>
|
|
1608
|
+
<span className="text-xs font-semibold text-accent">${mobilePreviewOpen ? 'Ocultar' : 'Mostrar'}</span>
|
|
1609
|
+
</button>
|
|
1610
|
+
${mobilePreviewOpen
|
|
1611
|
+
? html`<div className="mt-3">
|
|
1612
|
+
<${PackPreviewPanel} preview=${preview} quality=${quality} compact=${true} />
|
|
1613
|
+
</div>`
|
|
1614
|
+
: html`<p className="mt-2 text-xs text-slate-400">Toque para visualizar capa, descrição e score do pack.</p>`}
|
|
1615
|
+
</div>
|
|
1616
|
+
</div>
|
|
1617
|
+
|
|
1618
|
+
${error ? html`<div className="mt-3 rounded-2xl border border-rose-400/25 bg-rose-400/5 px-3 py-2.5 text-sm text-rose-200 md:mt-4 md:px-4 md:py-3">${error}</div>` : null}
|
|
1619
|
+
</div>
|
|
1620
|
+
|
|
1621
|
+
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-line/70 bg-panel/95 p-3 backdrop-blur md:hidden">
|
|
1622
|
+
<div className="mx-auto w-full max-w-7xl">
|
|
1623
|
+
<div className="mb-2 flex items-center justify-between gap-2">
|
|
1624
|
+
<button type="button" className="h-8 rounded-full border border-line/70 bg-panelSoft/80 px-3 text-xs font-semibold text-slate-200 disabled:opacity-60" onClick=${restartCreateFlow} disabled=${busy} title="Limpar rascunho local">Recomeçar</button>
|
|
1625
|
+
<button type="button" className="h-8 rounded-full border border-line/70 bg-panelSoft/60 px-3 text-xs font-semibold text-slate-300" onClick=${toggleMobilePreview} aria-expanded=${mobilePreviewOpen ? 'true' : 'false'}>${mobilePreviewOpen ? 'Ocultar preview' : 'Preview'}</button>
|
|
1626
|
+
</div>
|
|
1627
|
+
<div className="grid grid-cols-[1fr_1.45fr] gap-2">
|
|
1628
|
+
<button type="button" className="h-11 rounded-xl border border-line/70 bg-panelSoft/80 text-sm font-bold disabled:opacity-60" onClick=${prevStep} disabled=${step === 1 || busy}>Voltar</button>
|
|
1629
|
+
${step < 3 ? html` <button type="button" className=${`h-11 rounded-xl text-sm font-extrabold disabled:opacity-60 ${mobilePrimaryActionClass}`} onClick=${nextStep} disabled=${busy}>${mobilePrimaryActionLabel}</button> ` : html` <button type="button" className=${`h-11 rounded-xl text-sm font-extrabold disabled:opacity-60 ${mobilePrimaryActionClass}`} onClick=${handleFinalStepPrimaryAction} disabled=${finalStepPrimaryDisabled}>${mobilePrimaryActionLabel}</button> `}
|
|
1630
|
+
</div>
|
|
1631
|
+
</div>
|
|
1632
|
+
</div>
|
|
1633
|
+
|
|
1634
|
+
<div className="mt-6 hidden items-center justify-end gap-2 px-6 pb-6 md:flex">
|
|
1635
|
+
<button type="button" className="h-10 rounded-xl border border-line/70 bg-panelSoft/80 px-4 text-sm font-bold disabled:opacity-60" onClick=${restartCreateFlow} disabled=${busy} title="Limpar rascunho local e recomeçar">Recomeçar</button>
|
|
1636
|
+
<button type="button" className="h-11 rounded-xl border border-line/70 bg-panelSoft/80 px-5 text-sm font-bold" onClick=${prevStep} disabled=${step === 1 || busy}>Voltar</button>
|
|
1637
|
+
${step < 3 ? html`<button type="button" className="h-11 rounded-xl bg-accent px-5 text-sm font-extrabold text-slate-900" onClick=${nextStep} disabled=${busy}>Próximo passo</button>` : html`<button type="button" className="h-11 rounded-xl bg-accent2 px-5 text-sm font-extrabold text-slate-900 disabled:opacity-60" onClick=${handleFinalStepPrimaryAction} disabled=${finalStepPrimaryDisabled}>${finalStepPrimaryLabel}</button>`}
|
|
1638
|
+
</div>
|
|
1639
|
+
</div>
|
|
1640
|
+
`;
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
const root = document.getElementById('create-pack-react-root');
|
|
1644
|
+
if (root) {
|
|
1645
|
+
createRoot(root).render(html`<${CreatePackApp} />`);
|
|
1646
|
+
}
|