diff --git a/.env.example b/.env.example index aaf65dec..5eda0c0d 100644 --- a/.env.example +++ b/.env.example @@ -24,13 +24,13 @@ EMAIL_FROM='"Topiqu" ' # xAI — Grok visibility monitoring with native web search XAI_API_KEY="your_xai_api_key" -# Anthropic — Claude visibility monitoring with native web search +# Anthropic — Claude Haiku 5.5 for every text task (writing, research, translation), plus Claude visibility monitoring ANTHROPIC_API_KEY="your_anthropic_api_key" # Google Generative AI — Gemini visibility monitoring with search grounding GOOGLE_GENERATIVE_AI_API_KEY="your_google_ai_api_key" -# OpenAI — GPT-5.6 Luna editorial tasks, GPT-6 Luna routine tasks, images and embeddings +# OpenAI — images, embeddings and ChatGPT visibility monitoring OPENAI_API_KEY="your_openai_api_key" # Meta Model API — Muse Spark visibility monitoring with search grounding diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 70e1b88c..cc1641bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,9 @@ jobs: - name: Typecheck run: bun run typecheck + # vue-tsc outgrew Node's default heap on the runner (OOM since 2026-10-09). + env: + NODE_OPTIONS: --max-old-space-size=6144 test: name: Test @@ -191,6 +194,9 @@ jobs: - name: Build production output run: bun run build:docker + # `nuxt build` runs on Node here (the image builds on Bun) and sits near the default heap. + env: + NODE_OPTIONS: --max-old-space-size=6144 - name: Install browser engines run: bunx playwright install --with-deps chromium webkit diff --git a/MAP.md b/MAP.md index 3444000c..c6348072 100644 --- a/MAP.md +++ b/MAP.md @@ -25,7 +25,7 @@ The Nitro inline rule for `nuxt/dist` accepts both path separators to work aroun - **DB / ORM:** ZenStack ORM v3.9 with its PostgreSQL dialect (domain-split `prisma/schema.zmodel` + `prisma/models/*.zmodel` → ignored `generated/zenstack/` TypeScript schema/types, ~50 models). Prisma is not an application dependency; the ZenStack CLI carries Prisma internally only for migration-engine compatibility. See §6. - **Editor:** Tiptap 3 (custom extensions in `extensions/`: `Poll`, `slashCommand`, `indent`, `figure`, `image`, `aiDisclosure`). - **Body images are `
`** (`extensions/figure.ts`): `Figure` + a single `Figcaption` child, so heading/list commands cannot turn the caption into a block and drop the image. Attributes live on the `` (`alt`, `title`, `width`/`height` = intrinsic size for CLS, `data-media-id`), on a wrapping `` (`href`), and on the figure (`style="width: N%"` = `displayWidth`, `data-align` incl. `float-left`/`float-right`). Parsing accepts only 10–100 % in 5 % steps, the known aligns and http(s)/mailto/relative links (`app/utils/articleFigure.ts`), because DOMPurify keeps any `style`. The legacy AI shape `


caption

` is upgraded to a figure when an article is opened; an image in running text stays the inline `image` node. `AiDisclosure` keeps `` through edits. Selecting an image (the `figureSelect` plugin; ProseMirror only click-selects leaves) shows `Tiptap/ToolbarImageBubble.vue`: alt/title/link (`ImageDetails.vue`, optional write-back to `MediaAsset.defaultAltText`), width presets, alignment, replace from library, delete. `File/TiptapImageFrame.vue` adds drag-resize handles and a missing-alt badge. Floats apply from `sm` up (`main.css`); a linked image navigates instead of opening the lightbox. -- **AI:** AI SDK 7 with `@ai-sdk/openai`. All task, image, embedding and visibility model IDs live in `server/utils/ai/modelRegistry.ts`. `ai/models.ts` selects OpenAI Responses explicitly, native web search, image generation and embeddings. Editorial tasks (including research, writing and editing), topic scope, keyword opportunity and backlink matching use `gpt-5.6-luna`; routine text tasks use `gpt-6-luna`. This original division is guarded by `tests/unit/aiModels.test.ts`. Images use `gpt-image-2.5-flare` with explicit `medium` quality; knowledge embeddings remain `text-embedding-3-small` at 1536 dimensions. Visibility HTTP adapters read the central `AI_VISIBILITY_MODELS` registry and run only providers with a configured API key. Optional provider model IDs retain their original values. Research/writing reasoning and output budgets remain explicit at their call sites. `tests/server/ai/models.test.ts` exercises the actual SDK request and response contract for research, writing and translation without calling a live API. +- **AI:** AI SDK 7 with `@ai-sdk/anthropic` and `@ai-sdk/openai`. All task, image, embedding and visibility model IDs live in `server/utils/ai/modelRegistry.ts`. Every text task runs on `claude-haiku-5-5`, except `visibility`, which measures ChatGPT and stays on OpenAI `gpt-6-luna`; Claude visibility (`AI_VISIBILITY_MODELS.ANTHROPIC`) also uses Haiku at `low` effort. `ai/models.ts` picks the provider per task (`aiModel`, `aiWebSearchTool(task, depth)`: Anthropic `web_search_20250305` with `maxUses` 3/5/8, or OpenAI `searchContextSize`); `ai/reasoning.ts → aiReasoning(effort)` carries options for both providers (`none` disables Haiku thinking at `low` effort) and is imported explicitly so stubbed tests need no global. Haiku rejects `temperature`/`top_p`/`top_k` and thinks out of the same `max_tokens` pool, so `aiModel` wraps it in middleware adding `THINKING_HEADROOM` (8k) to every call-site `maxOutputTokens` unless thinking is disabled; call-site budgets size the answer only. Guarded by `tests/unit/aiModels.test.ts`. Images use `gpt-image-2.5-flare` with explicit `medium` quality; knowledge embeddings remain `text-embedding-3-small` at 1536 dimensions. Visibility HTTP adapters read the central `AI_VISIBILITY_MODELS` registry and run only providers with a configured API key. Optional provider model IDs retain their original values. Research/writing reasoning and output budgets remain explicit at their call sites. `tests/server/ai/models.test.ts` exercises the actual SDK request and response contract for research, writing and translation without calling a live API. - Manual generation **streams**: `server/utils/ai/article.ts` splits into `researchTopic` (live-web facts and URLs plus a dedicated YouTube lookup that tries up to three retrieved, oEmbed-verified candidates when selected) / `buildArticleConfig` (Prisma preferences + research-only factual grounding + `articleSchema`) / `finalizeArticle` (licensed Wikimedia/Openverse lookup with progressively shorter archive queries, generated fallback only where editorially safe, then numbered media-slot replacement) / blocking and streaming writers. `POST /api/articles/generate` emits NDJSON progress, the actual reservation, finalized content, then an authoritative `billing` event with the new balance. Events carry outcomes, not just counts: research fallback reason, the YouTube outcome (`found` with oEmbed title / `noCandidates` / `rejected` / `failed` / `researchOff`), per-slot image source (`official` / `screenshot` / `library` / `ai` / `null`), review issues, server-computed `missingModules` on `final`, and `stage` + `creditReturned` on `error`. The client folds them with the pure `reduceGenerationRun` / `generationSteps` (`shared/utils/articleGeneration.ts`) into one `GenerationRun`, rendered by `Article/Editor/GenerationRun.vue` above the title — progress while running, the report (with retry) afterwards; the sidebar only holds the preset form. The run itself lives in the `articleGeneration` Pinia store, not the page: leaving the editor keeps it going, the editor re-attaches by target (`new` / article id) and `Article/GenerationActivity.vue` shows it elsewhere. Leaving a finished, unsaved run on its source language does not ask to discard: the editor writes the author's edits back into the store (`keepGeneratedEdits`), and only saving or dismissing clears it. The store is mirrored to `sessionStorage` and restored after hydration, so a full page load (the public site, a refresh) keeps it too; a run restored as running polls `GET /api/articles/generations/[id]/status` and takes the finished article from the session snapshot, marking the steps it never heard about as `resumed`. A disconnected stream keeps generating on the server; only `POST /api/articles/generations/[id]/stop` aborts it. Internal `[[IMAGE…]]`/poll/video markers are omitted from the streamed editor view; resolved images appear only when ready, while all non-final exits strip every remaining slot. Only slots carry assets: `unwrapContentSlots` lifts a marker out of an ``/`
` the writer wrapped around it, and `dropAuthoredImages` removes images the writer wrote itself. - **Editor sidebar = three tabs** (`Article/Editor/SettingsPanel.vue`, shared by the desktop aside and the mobile slideover through the page's `settingsTab`): _Article_ (cover, sources, series, tags, release), _AI_ (`GenerationForm.vue`, or `AiUpsell.vue` without `hasAiPlan`), _Checks_ (optimization, fact check — locked without an AI plan, since `/api/articles/fact-check` is AI-gated — and media rights; the tab badge shows media needing attention before the score). AI opens first only on a blank article / `?ai=1` / preset prompt; a finished run switches to Checks. Tabs keep hidden panes mounted (`unmountOnHide: false`), but `focusOptimizationTarget` still switches to _Article_ before scrolling, because a hidden pane has no box. - **No `v-if` on Tiptap bubble menus** (`Tiptap/Editor.vue`). Tiptap detaches their element, so unmounting one breaks Vue's patch and every later update of the page fails silently; generation toggles `edit`, which froze the whole editor sidebar. They hide through `shouldShow` + `editor.isEditable` instead. @@ -49,7 +49,7 @@ The Nitro inline rule for `nuxt/dist` accepts both path separators to work aroun - **Search Console Autopilot is an explicit write opt-in, gated by active `SEARCH_CONSOLE` + `AI` features (PREMIUM; configurable CUSTOM).** The nightly `search-console-sync` stores finalised `date/page/query` performance, then `search-console-autopilot` atomically claims each opted-in connection and changes at most one published article per day. A page-one query with ≥200 impressions and CTR below 2% may rewrite only title+excerpt; a position-4–20 query may append one focused section. Audit history enforces seven days between writes to one article and 30 days for the same article/query pair. Existing article URLs are removed from the new-article signals sent to `generate-article`, preventing the obvious cannibalisation path; growing uncovered queries may reprioritise that cron but cannot override focus/novelty. Every write stores before/after data in the immutable audit chain, invalidates feeds, stales translations and exposes a guarded rollback that refuses to overwrite later human edits. Losing either feature disables the opt-in, so it never silently resumes after a downgrade. - **Image model note.** `articleImage` moved off **Imagen 4 Fast** because Google shuts the Imagen family down on **2026-08-17**; `tests/unit/aiModels.test.ts` now fails on any `imagen-*` id so it cannot come back. Nano Banana 2 Lite is a **Gemini image model** (`:generateContent`), not an Imagen one (`:predict`) — `google.image()` fronts both, so `models.ts` is unchanged, but Gemini image models ignore `n`/`maxImagesPerCall` (dropped from `ai/image.ts`) and return PNG rather than WebP, which `imageExtension(output.image.mediaType)` already handles. - **Cloud / Infra:** AWS S3, Rekognition, SES; Stripe (`@unlok-co/nuxt-stripe`); Vercel deploy (`vercel.json`) — migrating to Dokploy/VPS, see `MIGRATION.md`; container image at `Dockerfile` (multi-stage bun, `node-server` preset via `NITRO_PRESET`); PWA (`@vite-pwa/nuxt`). **No browser runtime:** OG images render via `@takumi-rs/*` (Rust/WASM) and both PDF routes via `pdfkit` — there is no Playwright/Chromium/jsPDF in the stack, and `playwright-core` was removed as a dead dependency. Don't reintroduce a headless browser without revisiting `MIGRATION.md §1`. -- **Media Library:** `MediaAsset` is the tenant-owned reusable asset record for uploaded, externally referenced, stock, and Topiqu-generated media; storage remains in the existing S3/CDN layer. `/admin/media` and `components/Media/{Library,Picker,Detail}.vue` provide one search/filter/select workflow reused by featured images and Tiptap body images. `ArticleMediaUsage` is a rebuildable read index derived from article HTML and `coverMediaId`, never the source of truth: indexing errors are reported but cannot turn an already persisted article save into a false failure. Every request route combines the active tenant capability check with a tenant-constrained lookup, and selected cover IDs are revalidated server-side. Deletion is archive-first, refuses referenced assets, waits seven days, then `media-gc` rechecks all article/draft references before deleting only allowlisted tenant-owned storage prefixes. `scripts/backfill-media-library.ts` is dry-run by default and idempotently registers legacy cover/body URLs, enriches existing assets, and rebuilds usage rows; run it with `APPLY=1` only after reviewing its report. Rights/provenance metadata remains on `MediaAsset`, so the library and pre-publish media-rights checks share one record rather than diverging systems. +- **Media Library:** `MediaAsset` is the tenant-owned reusable asset record for uploaded, externally referenced, stock, and Topiqu-generated media; storage remains in the existing S3/CDN layer; external and stock assets are hotlinked, so `Article.imageUrl` / `ArticleSeries.imageUrl` allow 2048 characters like `MediaAsset.url`. `/admin/media` and `components/Media/{Library,Picker,Detail}.vue` provide one search/filter/select workflow reused by featured images and Tiptap body images. `ArticleMediaUsage` is a rebuildable read index derived from article HTML and `coverMediaId`, never the source of truth: indexing errors are reported but cannot turn an already persisted article save into a false failure. Every request route combines the active tenant capability check with a tenant-constrained lookup, and selected cover IDs are revalidated server-side. Deletion is archive-first, refuses referenced assets, waits seven days, then `media-gc` rechecks all article/draft references before deleting only allowlisted tenant-owned storage prefixes. `scripts/backfill-media-library.ts` is dry-run by default and idempotently registers legacy cover/body URLs, enriches existing assets, and rebuilds usage rows; run it with `APPLY=1` only after reviewing its report. Rights/provenance metadata remains on `MediaAsset`, so the library and pre-publish media-rights checks share one record rather than diverging systems. - **Cache / Redis:** Upstash Redis (HTTP/REST, serverless-friendly) — cross-instance cache-aside in `server/utils/cache.ts`. Provisioned via the **Vercel Upstash marketplace integration**, which injects `UPSTASH_REDIS_REST_URL` + `UPSTASH_REDIS_REST_TOKEN`. **Vercel-coupled dependency:** on a hosting migration these creds must be re-provisioned/rotated (and the integration replaced by direct Upstash or another Redis-compatible provider). The cache degrades gracefully — missing creds disable caching, they don't break requests. - **SEO:** `@nuxtjs/seo`, `nuxt-og-image`, `nuxt-gtag`. - **Security:** `nuxt-security`, `isomorphic-dompurify`, `@zxcvbn-ts/core`, fingerprintjs. @@ -344,7 +344,7 @@ Automated AI translations turn the mono-lingual `Article` into a multi-lingual o - **Lifecycle as queue.** `status`: `PENDING → TRANSLATING → READY → PUBLISHED` (+ `STALE` on source edit, `FAILED`). Body fields (`slug`/`title`/`content`) are **nullable** — a queued row is a translation _request_ without a body until the cron fills it. `@@unique([articleId, language])` (one row per language) + `@@unique([slug, clientSiteId, language])` (localized slug namespace, separate from the source's `@@unique([slug, clientSiteId])` — locale-scoped routing means they never collide). - **Config (`ClientSite`).** `translationMode` (`OFF`/`MANUAL`/`AUTO`/`HYBRID`) + `translationLanguages[]` (empty = all supported langs except primary). Set in the AI preferences form (`Form/Client/AI.vue` → `common.preferences.translation.*`). Gated on the active `AI` **ClientFeature** + plan `PRO`/`PREMIUM`/`CUSTOM` (see §6b). The "empty means all" rule lives in one place — `translationQueue.ts → resolveTargetLanguages`, shared by the enqueue cron and the review panel's endpoint, tested in `tests/server/ai/translationQueue.test.ts`; read as "none" it would silently disable translation for every tenant that never picked languages. -- **Engine (deterministic).** `server/utils/ai/translate.ts → generateTranslation` uses `aiModel('translation')` (GPT-6 Luna — this is the most output-token-heavy call in the app at up to 20k, so it stays on the cheapest capable tier; Luna's 1M context also leaves headroom on `BATCH_SIZE` in `server/tasks/translate-pending.ts`). Poll/embed/img blocks are **masked out with cheerio** before the model sees them (`maskContentBlocks`) — `data-poll-id`/`optionId`/image `src` never travel through the LLM as free text; poll question+labels, image `alt`/`title`, **and human-readable attributes on surviving elements (`title`/`aria-label`, e.g. link tooltips — replaced in-place with `[[ATTR_n]]` tokens)** are translated as ordered structured fields and zipped back onto server-held markup on `rebuildContent` (empty/absent alt stays untouched → decorative images preserved; attribute values are **HTML-escaped** on re-injection so a translated `"`/`&` can't break out of the attribute). Twitter embeds restored verbatim. Pure of billing — the caller logs usage via `recordAiUsage('TRANSLATE_ARTICLE')`. +- **Engine (deterministic).** `server/utils/ai/translate.ts → generateTranslation` uses `aiModel('translation')` (Haiku 5.5 — this is the most output-token-heavy call in the app at up to 20k, so it stays on the cheapest capable tier; the 1M context also leaves headroom on `BATCH_SIZE` in `server/tasks/translate-pending.ts`). Poll/embed/img blocks are **masked out with cheerio** before the model sees them (`maskContentBlocks`) — `data-poll-id`/`optionId`/image `src` never travel through the LLM as free text; poll question+labels, image `alt`/`title`, **and human-readable attributes on surviving elements (`title`/`aria-label`, e.g. link tooltips — replaced in-place with `[[ATTR_n]]` tokens)** are translated as ordered structured fields and zipped back onto server-held markup on `rebuildContent` (empty/absent alt stays untouched → decorative images preserved; attribute values are **HTML-escaped** on re-injection so a translated `"`/`&` can't break out of the attribute). Twitter embeds restored verbatim. Pure of billing — the caller logs usage via `recordAiUsage('TRANSLATE_ARTICLE')`. - **Two trigger paths.** (1) On-demand `POST /api/articles/[id]/translate` (MANUAL/HYBRID); (2) cron `server/tasks/translate-pending.ts` (every 5 min) drains `PENDING`/`STALE` with a **per-row atomic claim** (guarded `updateMany` → `TRANSLATING`) so concurrent runs never double-translate — AUTO → `PUBLISHED`, HYBRID → `READY` (awaiting review), out-of-budget rows release back to `PENDING`. Enqueue/STALE is wired into all four publish paths via `server/utils/ai/translationQueue.ts → syncArticleTranslationQueue` (`articles/index.post`, `[id]/index.patch`, `publish-check`, `generate-article`). STALE keys off an explicit content-change signal, not `Article.updatedAt` (bumped by the view counter), to avoid token churn. - **Shared slug dedupe.** `server/utils/ai/translationSlug.ts → dedupeTranslationSlug` (used by both endpoint and cron). SEO rendering: see §8. - **Every endpoint the article page calls must resolve the slug the same way** (`server/utils/articleBySlug.ts → resolveArticleBySlug`, `tests/server/articles/articleBySlug.test.ts`). On a non-primary locale the URL carries the `ArticleTranslation` slug, which does not exist on `Article` at all. `[id]/index.get.ts` handled that; `[id]/related` never did, and its `findUniqueOrThrow` turned it into an unhandled **500 on every `/en` article view** — latent until publishing a translation plus the mismatched-locale redirect made translated slugs actually reachable. The resolver is now shared: translation first (`PUBLISHED`, or any status for an admin), source row as fallback for an untranslated locale, `null` → 404 rather than P2025. `related` also localizes its cards via `localizeArticles` and is **scoped to `clientSiteId`** — `Tag.clientSiteId` is nullable, so a global tag matched articles across tenants. @@ -368,7 +368,7 @@ Marketing names diverge from the DB enum: marketing **FREE** = enum **BASIC**. E **A literal `@` in any i18n message breaks the production build.** vue-i18n reads `@` as the start of a linked message (`@:key`), so an e-mail address in a message fails with `Invalid linked format`. It only surfaces under `jit: true`, which the build uses and neither `typecheck` nor `vitest` does — write `@` instead (these messages render through `v-html`, so the entity decodes in both the link text and the `mailto:` href). -**Legal texts** (`i18n/locales/{cs,en}/legal.json`, rendered by `pages/privacy.vue` / `pages/tos.vue`) are a **duplicate of the same file in `../landing`** — edit there and copy across, or the two sites drift. The two copies differ only in line endings (app LF, landing CRLF), so compare with `diff --strip-trailing-cr`. The `[[BRACKETS]]` placeholders are filled (as of 10 Aug 2026); the ARES link is built from the IČO, so both change together. The postal address stays out on purpose — ARES carries it. The processor list names no hosting provider on purpose — app, DB and cache run on own hardware, so there is no hosting processor to disclose under Art. 13(1)(e), and the "transfers outside the EEA" paragraph therefore states no baseline region. The LinkedIn section is written to match the retention limits in the LinkedIn API Terms (profile ≤24 h, social activity ≤48 h) — changing what `server/utils/linkedin/` stores means changing that section too. +**Legal texts** (`i18n/locales/*/legal.json`, rendered by `pages/privacy.vue` / `pages/tos.vue`; the cs/en copies) are a **duplicate of the same file in `../landing`** — edit there and copy across, or the two sites drift. The two copies differ only in line endings (app LF, landing CRLF), so compare with `diff --strip-trailing-cr`. The `[[BRACKETS]]` placeholders are filled (as of 10 Aug 2026); the ARES link is built from the IČO, so both change together. The postal address stays out on purpose — ARES carries it. The processor list names no hosting provider on purpose — app, DB and cache run on own hardware, so there is no hosting processor to disclose under Art. 13(1)(e), and the "transfers outside the EEA" paragraph therefore states no baseline region. The LinkedIn section is written to match the retention limits in the LinkedIn API Terms (profile ≤24 h, social activity ≤48 h) — changing what `server/utils/linkedin/` stores means changing that section too. The Search Console section carries what Google OAuth verification for `webmasters.readonly` requires (scope, data, purpose, Limited Use link, deletion); keep it when trimming. AI vendors are named as the category "AI model providers", so swapping a model needs no policy change. Section numbers are plain text: keep them sequential. **Who may write which `ClientSite` field.** `shared/utils/clientSiteFields.ts` is the single source: `PRIVILEGED_CLIENT_SITE_FIELDS` (`plan`, `tokenLimit`) are **superadmin-only**; everything in `TENANT_EDITABLE_CLIENT_SITE_FIELDS`, including a tenant's own `gamNetworkCode`, is fair game for a site's own admin. `server/api/clients/[id]/index.patch.ts` builds a Zod pick from the applicable list, so a tenant's privileged extra keys are silently stripped rather than rejected. The partition is pinned by `tests/unit/clientSiteFields.test.ts`; it validates field names against generated ZenStack schema metadata. Reactivating a soft-deleted site (`deletedAt: null`) is superadmin-only too; self-deactivation stays open. Side effects must read the **parsed** value, never raw body — `requestedTokenLimit`, not `scalarBody.tokenLimit`. @@ -585,7 +585,7 @@ The app had **zero runtime observability**: when something broke for a real user - Game image discovery now queries the official Steam store screenshot catalogue before Openverse/Wikimedia or synthesis, independently of the text citation list (`server/utils/images/steam.ts`). Exact game-name/number matching prevents substituting prior installments, and per-article catalogue caches supply distinct assets for cover and body. Credits always identify the original game and publisher; catalogue shots are labelled illustrations rather than inheriting unverified scene descriptions. -- Body image captions (`ai/articleMedia.ts` → `images/caption.ts`): the writer's caption describes the image it asked for, so only a freshly generated (`kind: 'ai'`) picture uses it as caption and `alt`; searched photos keep provider metadata and the credit line. A tenant with `discloseAiContent` off loses the AI label span in `Article/Parsed.vue`. +- Body image captions (`ai/articleMedia.ts` → `images/caption.ts`): the writer's caption describes the image it asked for, so only a freshly generated (`kind: 'ai'`) picture uses it, as `alt` only; its figcaption holds just the AI label span, which `Article/Parsed.vue` strips (and then drops the empty caption) for a tenant with `discloseAiContent` off. Searched photos keep provider metadata and the credit line. ## Shopify article publishing diff --git a/README.md b/README.md index 6fb0d4ac..54879c48 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ ZenStack generates ignored schema metadata and TypeScript types in `generated/ze | **State & queries** | Pinia · Pinia Colada | | **Data** | ZenStack ORM 3 · PostgreSQL · pgvector | | **Editor** | Tiptap 3 with custom extensions | -| **AI** | AI SDK 7 · OpenAI Responses API | +| **AI** | AI SDK 7 · Claude Haiku 5.5 · OpenAI | | **Runtime** | Bun 1.4.2 · Node 24.21.0 |
diff --git a/app/app.vue b/app/app.vue index c3e0d350..08926f1b 100644 --- a/app/app.vue +++ b/app/app.vue @@ -2,21 +2,24 @@ - - + + - - + + - + - + @@ -36,6 +39,9 @@ import { activeBrandGradient, gradientCss } from '~~/shared/utils/publicationBra const reqUrl = useRequestURL() const route = useRoute() +const { active: isWidgetPagePreview } = useWidgetPagePreview() +useWidgetPagePreviewBridge() +useHead(() => ({ meta: isWidgetPagePreview.value ? [{ name: 'robots', content: 'noindex, nofollow' }] : [] })) const clientSite = await useClientSite() const liveClientSite = await useLiveClientSite() const i18nHead = useLocaleHead() @@ -45,7 +51,7 @@ const { data: auth } = useAuth() const uiLocales = { cs, de, en, fr } const uiLocale = computed(() => uiLocales[locale.value as keyof typeof uiLocales] ?? en) const isBrowserTest = Boolean(useRuntimeConfig().public.browserTest) -const { marketingGranted } = useConsent(() => clientSite) +const { marketingGranted } = useConsent(() => liveClientSite.value) const isArticleRoute = computed(() => String(route.name ?? '').startsWith('clanky-slug')) const isPublicationSurface = computed(() => resolvePageShell(route.meta.shell) === 'publication') diff --git a/app/components/AdSenseLoader.vue b/app/components/AdSenseLoader.vue index 3e0dfab8..d2863943 100644 --- a/app/components/AdSenseLoader.vue +++ b/app/components/AdSenseLoader.vue @@ -26,6 +26,10 @@ watch( status.value = 'loading' if (!ready) return + const adWindow = window as unknown as { adsbygoogle?: unknown[] & { requestNonPersonalizedAds?: number } } + const queue = (adWindow.adsbygoogle ??= []) + queue.requestNonPersonalizedAds = 1 + // AdSense validates its head tag. Unhead adds data-hid and event marker attributes, // so insert a plain script only after hydration and attach listeners through the DOM. const element = document.createElement('script') diff --git a/app/components/Article/Feedback.vue b/app/components/Article/Feedback.vue index ab15ac5f..16ad6008 100644 --- a/app/components/Article/Feedback.vue +++ b/app/components/Article/Feedback.vue @@ -97,10 +97,6 @@ const votedCookie = useCookie(`feedback_voted_${props.articleI sameSite: 'lax', }) -const sessionCookie = useCookie('client_session_id', { maxAge: 60 * 60 * 24 * 365 }) -if (!sessionCookie.value) - sessionCookie.value = crypto.randomUUID?.() ?? Math.random().toString(36).substring(2) + Date.now().toString(36) - const hasVoted = computed(() => !!votedCookie.value) const submitVote = async (isHelpful: boolean) => { @@ -108,7 +104,7 @@ const submitVote = async (isHelpful: boolean) => { try { await $fetch(`/api/articles/${props.articleId}/feedback`, { method: 'POST', - body: { isHelpful, reason: isHelpful ? null : reasonText.value, sessionId: sessionCookie.value }, + body: { isHelpful, reason: isHelpful ? null : reasonText.value }, }) votedCookie.value = isHelpful ? 'helpful' : 'unhelpful' if (isHelpful) toast.add({ color: 'success', title: $t('feedback.thanks_short') }) diff --git a/app/components/ConsentManager.vue b/app/components/ConsentManager.vue index 52fdfce4..3a18bdb5 100644 --- a/app/components/ConsentManager.vue +++ b/app/components/ConsentManager.vue @@ -10,161 +10,104 @@
- - + +
-
- +
+ {{ $t('common.consent.reject') }} - + + {{ $t('common.consent.accept') }} + + {{ $t('common.consent.customize') }} - {{ $t('common.consent.accept') }}
- - - - - - + diff --git a/app/components/ConsentSettings.vue b/app/components/ConsentSettings.vue new file mode 100644 index 00000000..1c900717 --- /dev/null +++ b/app/components/ConsentSettings.vue @@ -0,0 +1,131 @@ + + + diff --git a/app/components/Footer.vue b/app/components/Footer.vue index 8bb88e39..0b7c64fc 100644 --- a/app/components/Footer.vue +++ b/app/components/Footer.vue @@ -22,6 +22,17 @@ Topiqu {{ clientSite.name }} +
+ + {{ $t('common.consent.moreInfo') }} + + + {{ $t('common.consent.openSettings') }} + +
@@ -29,6 +40,8 @@ diff --git a/app/components/Form/Client/Widget/AnnouncementText.vue b/app/components/Form/Client/Widget/AnnouncementText.vue new file mode 100644 index 00000000..cd377888 --- /dev/null +++ b/app/components/Form/Client/Widget/AnnouncementText.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/components/Form/Client/Widget/Gallery.vue b/app/components/Form/Client/Widget/Gallery.vue deleted file mode 100644 index bc84aa4a..00000000 --- a/app/components/Form/Client/Widget/Gallery.vue +++ /dev/null @@ -1,44 +0,0 @@ - - - diff --git a/app/components/Form/Client/Widget/Library.vue b/app/components/Form/Client/Widget/Library.vue new file mode 100644 index 00000000..4abea38c --- /dev/null +++ b/app/components/Form/Client/Widget/Library.vue @@ -0,0 +1,103 @@ + + + diff --git a/app/components/Form/Client/Widget/PagePreview.vue b/app/components/Form/Client/Widget/PagePreview.vue new file mode 100644 index 00000000..e72a000f --- /dev/null +++ b/app/components/Form/Client/Widget/PagePreview.vue @@ -0,0 +1,83 @@ +