diff --git a/database/schema.sql b/database/schema.sql index 170312d..3650b9c 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -302,6 +302,8 @@ CREATE TABLE IF NOT EXISTS external_news_sources ( provider TEXT NOT NULL CHECK(provider IN ('ubisoft')), external_id TEXT NOT NULL, canonical_url TEXT NOT NULL, + thumbnail_url TEXT, + thumbnail_alt TEXT NOT NULL DEFAULT '', source_published_at TEXT NOT NULL, content_hash TEXT NOT NULL CHECK(length(content_hash)=64), first_seen_at TEXT NOT NULL, diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 26db4e3..bdf3317 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -606,6 +606,11 @@ editorial group, and Recommended Guides carry a textual badge and filter. Search relevance is not overridden by curation. Guide cards pair a cover or field-manual fallback with title, deck, author, publication date, tags, and the rating count. Unlisted and private articles never enter discovery or search. +Official Ubisoft News cards use the validated source thumbnail through the +same-origin image proxy and link their cover and headline to the canonical +Ubisoft article. The `BY UBISOFT` metadata identifies the external destination +before activation. Internal News and all Guides continue to use SHD.build +article routes. Profile conversations begin with a collapsed `LEAVE A MESSAGE` action. A reply opens the same editor beneath the selected message, and only one editor remains diff --git a/docs/architecture.md b/docs/architecture.md index 125af10..dbe0edc 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -123,25 +123,30 @@ comments still use community moderation. The Ubisoft News importer reads the English-language Division Resurgence feed from Ubisoft Nimbus. It stores each Nimbus item ID as the stable external ID and -publishes only the source title, excerpt, and canonical Ubisoft link. It does -not copy the source article body or images. Imported articles use Ubisoft's -original publication timestamp rather than the import time. +publishes the source title, excerpt, canonical Ubisoft link, and validated +Ubisoft-hosted thumbnail. SHD.build does not copy the article body or image +file. A bounded same-origin endpoint proxies each thumbnail so opening the News +listing does not send visitor metadata to Ubisoft. News cards link to the +canonical Ubisoft article. Imported articles use Ubisoft's original publication +timestamp rather than the import time. `external_news_sources` maps each Nimbus ID to one SHD.build article. A SHA-256 content hash covers the normalized title, excerpt, canonical URL, and source publication timestamp. An unchanged hash updates `last_seen_at` without creating -a revision. A changed hash creates and publishes an immutable revision. The -importer does not delete or unpublish an article when Nimbus omits it from a -later feed, and it does not restore an article that an editor deleted. The -Newsroom treats source-linked articles as read-only so local edits cannot retain -Ubisoft attribution. The importer defers future-dated items until their source -publication time. +a revision. Thumbnail changes update the audited source metadata without +creating an article revision. A changed content hash creates and publishes an +immutable revision. The importer does not delete or unpublish an article when +Nimbus omits it from a later feed, and it does not restore an article that an +editor deleted. The Newsroom treats source-linked articles as read-only so local +edits cannot retain Ubisoft attribution. The importer defers future-dated items +until their source publication time. The seeded, non-public `Ubisoft Official News` service identity owns imported articles and acts on their audit events. A first import records `editorial_content / news.imported`; a later content sync records `editorial_content / news.import_synced`. Audit snapshots identify the provider, -Nimbus ID, canonical URL, source timestamp, and resulting revision. +Nimbus ID, canonical URL, thumbnail metadata, source timestamp, and resulting +revision. The existing bearer-authenticated scheduler endpoint invokes the importer. A successful run sets the next check one hour later. The importer stores the last diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index b9796a8..6778629 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -679,6 +679,27 @@ if (articleRevisionColumns.some((column) => column.name === 'plaint_text')) { ); } +const externalNewsSourceColumns = await ( + await database.prepare("PRAGMA table_info('external_news_sources')") +).all(); +if ( + externalNewsSourceColumns.length && + !externalNewsSourceColumns.some((column) => column.name === 'thumbnail_url') +) { + console.log('Adding Ubisoft News thumbnail metadata.'); + await database.exec( + 'ALTER TABLE external_news_sources ADD COLUMN thumbnail_url TEXT', + ); +} +if ( + externalNewsSourceColumns.length && + !externalNewsSourceColumns.some((column) => column.name === 'thumbnail_alt') +) { + await database.exec( + "ALTER TABLE external_news_sources ADD COLUMN thumbnail_alt TEXT NOT NULL DEFAULT ''", + ); +} + const currentModerationScanColumns = await ( await database.prepare("PRAGMA table_info('content_moderation_scans')") ).all(); diff --git a/src/lib/components/ArticleCard.svelte b/src/lib/components/ArticleCard.svelte index b054adc..c4eef4a 100644 --- a/src/lib/components/ArticleCard.svelte +++ b/src/lib/components/ArticleCard.svelte @@ -1,13 +1,15 @@
- - {#if article.coverUrl}{:else}
SHD // {kind === 'guide' ? 'FIELD MANUAL' : 'TRANSMISSION'}{article.tags?.[0]?.name ?? article.channel ?? 'COMMUNITY INTELLIGENCE'}
{/if} +
+ {#if article.coverUrl}{article.coverAlt{:else}
SHD // {kind === 'guide' ? 'FIELD MANUAL' : 'TRANSMISSION'}{article.tags?.[0]?.name ?? article.channel ?? 'COMMUNITY INTELLIGENCE'}
{/if}
{article.channel ?? article.tags?.[0]?.name ?? kind.toUpperCase()}{#if article.recommended}RECOMMENDED{/if}{#if article.featured}FEATURED{/if}
-

{article.title}

+

{article.title}

{article.deck}

BY {(article.source?.name ?? article.author).toUpperCase()} · {new Date(article.publishedAt).toLocaleDateString()}{#if kind === 'guide'}{article.votes ? `★ ${article.rating.toFixed(1)} · ${article.votes}` : 'UNRATED'}{/if}
diff --git a/src/lib/components/ArticlePage.svelte b/src/lib/components/ArticlePage.svelte index 4db22ac..06d8795 100644 --- a/src/lib/components/ArticlePage.svelte +++ b/src/lib/components/ArticlePage.svelte @@ -29,7 +29,7 @@ {#if data.article.source}{/if} {#if data.tags.length}{/if} - {#if data.article.coverUrl}
{data.article.coverAlt}/{#if data.article.coverCaption}
{data.article.coverCaption}
{/if}
{/if} + {#if data.article.coverUrl}
{data.article.coverAlt}{#if data.article.coverCaption}
{data.article.coverCaption}
{/if}
{/if}
{#if data.article.kind === 'guide'}