Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions assets/archetype-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import AppMantineProvider from './components/AppMantineProvider';
import PlaystyleTagSelect from './components/PlaystyleTagSelect';
import PokemonSpriteSelect from './components/PokemonSpriteSelect';
import MarkdownEditor from './components/MarkdownEditor';
import { mountImageUrlFields } from './shared/mount-image-url-field';

import '@mantine/core/styles.css';
import '@mantine/tiptap/styles.css';
Expand Down Expand Up @@ -85,6 +86,11 @@ if (playstyleRoot) {
);
}

/**
* @see docs/features.md F18.30 — Editor-defined OG image and description on decks, archetypes, variants
*/
mountImageUrlFields();

const editorRoots = document.querySelectorAll<HTMLDivElement>('.rich-text-editor-root');
editorRoots.forEach((root) => {
const textareaId = root.dataset.textareaId;
Expand Down
6 changes: 6 additions & 0 deletions assets/deck-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import AppMantineProvider from './components/AppMantineProvider';
import ArchetypeSelect from './components/ArchetypeSelect';
import LanguageSelect from './components/LanguageSelect';
import PokemonSpriteSelect from './components/PokemonSpriteSelect';
import { mountImageUrlFields } from './shared/mount-image-url-field';

import '@mantine/core/styles.css';

Expand Down Expand Up @@ -68,6 +69,11 @@ if (languageRoot) {
);
}

/**
* @see docs/features.md F18.30 — Editor-defined OG image and description on decks, archetypes, variants
*/
mountImageUrlFields();

const spriteRoot = document.getElementById('pokemon-sprite-select-root');
if (spriteRoot) {
let initialSlugs: string[] = [];
Expand Down
49 changes: 2 additions & 47 deletions assets/page-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@
* file that was distributed with this source code.
*/

import React from 'react';
import { createRoot } from 'react-dom/client';
import AppMantineProvider from './components/AppMantineProvider';
import MarkdownEditor from './components/MarkdownEditor';
import ImageUrlField from './components/ImageUrlField';
import { mountImageUrlFields } from './shared/mount-image-url-field';

import '@mantine/core/styles.css';
import '@mantine/tiptap/styles.css';
Expand Down Expand Up @@ -43,48 +42,4 @@ editorRoots.forEach((root) => {
);
});

/**
* @see docs/features.md F10.6 — ImageUrlField component with drag-and-drop upload
*/

const imageUrlRoots = document.querySelectorAll<HTMLDivElement>('.image-url-field-root');
imageUrlRoots.forEach((root) => {
const inputId = root.dataset.inputId;
const uploadUrl = root.dataset.uploadUrl ?? '';
const serverError = root.dataset.error ?? '';

if (!inputId) {
return;
}

const hiddenInput = document.getElementById(inputId) as HTMLInputElement | null;
if (!hiddenInput) {
return;
}

const ImageUrlFieldWrapper = () => {
const [value, setValue] = React.useState(hiddenInput.value);
const [error, setError] = React.useState(serverError || null);

const handleChange = (url: string) => {
setValue(url);
hiddenInput.value = url;
setError(null);
};

return (
<ImageUrlField
value={value}
onChange={handleChange}
uploadUrl={uploadUrl}
serverError={error}
/>
);
};

createRoot(root).render(
<AppMantineProvider>
<ImageUrlFieldWrapper />
</AppMantineProvider>,
);
});
mountImageUrlFields();
73 changes: 73 additions & 0 deletions assets/shared/mount-image-url-field.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/*
* This file is part of the Expanded Decks project.
*
* (c) Expanded Decks contributors
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

/**
* Mounts the `ImageUrlField` React component over every `.image-url-field-root`
* element on the page, syncing its URL value back to a hidden Symfony input.
*
* Twig templates that render a Symfony image field via the
* `admin/_image_url_field.html.twig` macro produce both:
* - a hidden `<input type="text">` with the underlying Symfony field ID
* - a sibling `<div class="image-url-field-root" data-input-id=... data-upload-url=... data-error=...>`
*
* This helper finds those roots, replaces the visible UI with the Mantine
* drag-and-drop component, and pipes the resulting URL back into the hidden
* input so the standard Symfony form submission carries the value.
*
* @see docs/features.md F10.6 — ImageUrlField component with drag-and-drop upload
*/

import React from 'react';
import { createRoot } from 'react-dom/client';
import AppMantineProvider from '../components/AppMantineProvider';
import ImageUrlField from '../components/ImageUrlField';

export function mountImageUrlFields(): void {
const roots = document.querySelectorAll<HTMLDivElement>('.image-url-field-root');
roots.forEach((root) => {
const inputId = root.dataset.inputId;
const uploadUrl = root.dataset.uploadUrl ?? '';
const serverError = root.dataset.error ?? '';

if (!inputId) {
return;
}

const hiddenInput = document.getElementById(inputId) as HTMLInputElement | null;
if (!hiddenInput) {
return;
}

const Wrapper = () => {
const [value, setValue] = React.useState(hiddenInput.value);
const [error, setError] = React.useState(serverError || null);

const handleChange = (url: string) => {
setValue(url);
hiddenInput.value = url;
setError(null);
};

return (
<ImageUrlField
value={value}
onChange={handleChange}
uploadUrl={uploadUrl}
serverError={error}
/>
);
};

createRoot(root).render(
<AppMantineProvider>
<Wrapper />
</AppMantineProvider>,
);
});
}
2 changes: 2 additions & 0 deletions docs/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,8 @@ Multi-domain channel system and SEO features. Each channel serves a distinct dom
| F18.27 | JSON-LD structured data | Medium | Done | `StructuredDataBuilder` service generating schema.org JSON-LD blocks rendered in `{% block structured_data %}`. **Homepage:** `WebSite` with channel brand name. **CMS pages:** `WebPage` with title, date, publisher. **Archetypes:** `Article` with localized name/description, `hasPart` entries for deck variants (anchored by shortTag). **Events:** `Event` with dates, location, organizer, `EventCancelled` status. **Decks:** `CreativeWork` with owner, dates. Organization name reads from the channel's `brand_name` parameter. Twig functions: `structured_data()` (returns builder), `json_ld(data)` (encodes as safe HTML). Depends on F18.2. |
| F18.28 | Open Graph and Twitter Card meta tags | Medium | Done | Full Open Graph and Twitter Card meta tags on all public pages via a reusable `_partials/opengraph.html.twig` included from `{% block opengraph %}`. Tags: `og:title`, `og:description`, `og:image`, `og:url`, `og:type`, `og:locale` (+ alternate), `og:site_name`, `twitter:card` (`summary_large_image` when image available), `twitter:title`, `twitter:description`, `twitter:image`. Page-specific images: CMS pages use `Page.ogImage`, decks use `mosaicImageUrl`, the homepage uses `HomepageLayout.ogImage` with a per-channel `og_default_image` parameter as fallback (#554), archetypes and events use site default. **Homepage `<title>` and `og:description` are per-locale and editor-configurable** via `HomepageLayoutTranslation.title` and `HomepageLayoutTranslation.ogDescription`; when `title` is empty the `<title>` falls back to the channel's `brand_name` parameter (no more app-channel branding leaking onto other channels). **`og:image` and `twitter:image` values are absolutized at render time** via `channel_absolute_url()` so editor-uploaded image paths (e.g. `/api/editor/image/banner.png`) become full `https://{channel.domain}/...` URLs — required because social-media crawlers don't resolve relative URLs. Consolidates existing partial OG tags from page and archetype templates. Depends on F18.25. |
| F18.29 | Locale-prefixed URL routing | High | Done | Add `/{_locale}/` prefix to all public editorial routes (archetypes, CMS pages) so that each language version has a distinct URL (e.g. `/en/archetypes/iron-thorns` vs `/fr/archetypes/iron-thorns`). The **homepage** (`/`) remains session-based for UX but its canonical points to the default locale (`/en/`); localized homepage routes (`/en/`, `/fr/`) also exist for SEO. Non-editorial routes (decks, events, auth, admin) retain the current session-based locale without URL prefix. A navbar locale switcher (`EN | FR`) lets users toggle between languages — on editorial routes it swaps the `_locale` in the URL directly; on session-based routes it goes through `LocaleSwitchController`. `LocaleListener` updated: route-level `_locale` now takes precedence over user/session preference. 301 redirects from all legacy unprefixed paths to `/en/` equivalents for SEO continuity. Sitemap generates entries for each locale for editorial content. robots.txt updated with locale-prefixed allow/disallow rules. Unblocks F18.26 (hreflang). |
| F18.30 | Editor-defined OG image and description on decks, archetypes, variants | Medium | Done | Editors override the social-share OG image and OG description per deck and per archetype locale. **`Deck`** gains non-translatable `ogImage` (varchar 255, URL regex) + `ogDescription` (TEXT). **`ArchetypeTranslation`** gains per-locale `ogImage` + `ogDescription` (kept independent from `metaDescription` so length can be tuned for social cards). A small stateless `App\Service\Seo\OgMetaResolver` centralises the fallback chain: for a deck, the deck's own values win; for an **archetype variant** (`owner === null` + `archetype` set, see `Deck::isArchetypeVariant()`) the resolver falls back to the parent archetype's locale-scoped values; finally `og_image` falls back to `deck.currentVersion.mosaicImageUrl` (preserving F18.28 behavior). For archetypes, `og_description` gracefully falls back to `archetype.localizedMetaDescription(locale)` when no OG-specific copy is supplied. Templates pass the resolved `ogImage` / `ogDescription` context vars into the existing `_partials/opengraph.html.twig` (no template-system changes — its null guards already cover the empty case). Reuses `EditorUploadController` at `/api/editor/upload-image` for image uploads. Admin labels live under `app.form.label.og_*`, `app.form.help.og_*`, `app.archetype.og_*`. Depends on F18.28. |
| F18.31 | Editor-defined OG image and description on Banned & Staple Cards pages | Medium | Done | Extends F18.30 to the two CMS listing pages (Banned Cards, Staple Cards), which pull their intro content from a `Page` entity via `ListingIntroPage::BANNED_CARDS_SLUG` / `STAPLE_CARDS_SLUG`. **`PageTranslation`** gains per-locale `ogImage` (varchar 255, URL regex) + `ogDescription` (TEXT); the existing parent-level `Page.ogImage` is kept as a default and overridden per-locale only when set. Render precedence: `PageTranslation.ogImage` → `Page.ogImage` → no `og:image` tag; `PageTranslation.ogDescription` → no `og:description` tag. `og:title` keeps the existing per-page translation key (`app.banned_card.public.title` / `app.staple_card.public.title`) rather than reusing `PageTranslation.title` because the listing intros hide the title field in admin (`is_listing_intro`). `templates/banned_card/list.html.twig` is wired to emit the OG image + description; `templates/staple_card/list.html.twig` gains a full `{% block opengraph %}` block (it had none). Admin labels live under `app.cms.form.og_*_localized` to differentiate from the parent-level `app.cms.form.og_image`. Depends on F18.30 (shared `OgMetaResolver`). |

---

Expand Down
4 changes: 3 additions & 1 deletion docs/models/cms.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ A CMS content page. Non-translatable fields live here; translatable fields (titl
| `slug` | `string(150)` | No | URL slug (e.g. `"how-to-borrow"`). |
| `menuCategory` | `MenuCategory` | Yes | Optional grouping for navigation (F11.2). Null = page not in any menu. |
| `isPublished` | `bool` | No | Whether the page is publicly visible. Default: `false`. |
| `ogImage` | `string(255)` | Yes | Open Graph image URL for social sharing. Accepts relative (`/api/editor/image/...`) or absolute URLs. |
| `ogImage` | `string(255)` | Yes | Open Graph image URL for social sharing — the **parent-level default**. Accepts relative (`/api/editor/image/...`) or absolute URLs. Overridden per-locale by [`PageTranslation.ogImage`](#pagetranslation) when set (F18.31). |
| `noIndex` | `bool` | No | Whether to add `<meta name="robots" content="noindex">`. Default: `false`. |
| `createdAt` | `DateTimeImmutable` | No | Creation timestamp. |
| `updatedAt` | `DateTimeImmutable` | No | Last modification timestamp. |
Expand Down Expand Up @@ -102,6 +102,8 @@ Localized fields for `Page`. One row per locale per page. Contains the page's ti
| `locale` | `string(5)` | No | ISO 639-1 locale code (e.g. `"en"`, `"fr"`). |
| `title` | `string(200)` | No | Translated page title. |
| `content` | `text` | No | Page body in **Markdown** format. Rendered to HTML via `league/commonmark` on display. |
| `ogImage` | `string(255)` | Yes | Per-locale override of `Page.ogImage` (F18.31). URL regex matches `Page.ogImage`. When empty, falls back to the parent-level value via `OgMetaResolver::resolveForPage()`. |
| `ogDescription` | `text` | Yes | Per-locale Open Graph description for social sharing (F18.31). No parent-level equivalent: when empty, no `og:description` tag is emitted. |

### Constraints

Expand Down
4 changes: 4 additions & 0 deletions docs/models/deck.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ Represents a **physical** Pokemon TCG deck — the deck box with a label. A deck
| `languages` | `json` | No | Array of ISO 639-1 language codes present in this deck (e.g. `["en", "ja"]`). Default: `[]`. |
| `status` | `string(20)` | No | Current availability status. See Status enum below. Default: `"available"`. |
| `notes` | `text` | Yes | Owner's private notes about the deck (e.g. sleeve color, missing cards, condition). |
| `ogImage` | `string(255)` | Yes | Editor-defined Open Graph image URL for the public deck page (F18.30). Accepts absolute or site-relative paths; validated by the same regex as `Page.ogImage`. When empty, the variant fallback chain in `OgMetaResolver::resolveForDeck()` tries the parent archetype's locale-scoped image (only when this row is an archetype variant), then `deck.currentVersion.mosaicImageUrl`. |
| `ogDescription` | `text` | Yes | Editor-defined Open Graph description for the public deck page (F18.30). When empty, falls back to the parent archetype's locale-scoped `ogDescription` if this is a variant; otherwise no `og:description` tag is emitted. |
| `public` | `bool` | No | Whether the deck is visible in the public catalog and accessible via its shortTag URL to anonymous users. Default: `false`. Cannot be unpublished while the deck has active event registrations. |
| `personal` | `bool` | No | Owner opt-out from lending and event registration. Default: `false`. **Orthogonal to `public`** — a personal deck can still be public and URL-viewable; only the borrow workflow and event registration are blocked. Cannot toggle on while the deck has active borrows or event registrations. See F2.30. |

Expand Down Expand Up @@ -148,6 +150,8 @@ A managed archetype entry representing a deck strategy (e.g. "Lugia VSTAR", "Iro
| `playstyleTags` | `json` | No | Array of free-text tag strings (e.g. `["Aggressive", "Combo"]`). Default: `[]`. Tags are normalized on save: title case, alphanumeric and spaces only. Admin form suggests existing tags from all archetypes and allows creating new ones via Mantine TagsInput. See F2.15. |
| `description` | `text` | Yes | Markdown content for the archetype detail page (F2.10). Rendered via `ArchetypeDescriptionRenderer` which processes Markdown and expands custom tags: `[[archetype:slug]]` (archetype link with sprites), `[[deck:SHORTTAG]]` (deck badge link), `[[card:SET-NUMBER]]` (card name with hover image). |
| `metaDescription` | `string(255)` | Yes | SEO meta description for the archetype detail page. Max 255 characters. |
| `ogImage` | `string(255)` | Yes | Per-locale Open Graph image URL for the archetype detail page (F18.30). Lives on `ArchetypeTranslation` so each locale can ship its own social-share image. URL regex matches `Page.ogImage`. |
| `ogDescription` | `text` | Yes | Per-locale Open Graph description for the archetype detail page (F18.30). Lives on `ArchetypeTranslation`. Kept independent from `metaDescription` so editors can tune length for social cards. When empty, `OgMetaResolver::resolveForArchetype()` falls back to `archetype.localizedMetaDescription(locale)`. |
| `isPublished` | `bool` | No | Controls visibility of the archetype detail page (F2.10). Default: `false`. |
| `deletedAt` | `DateTimeImmutable` | Yes | Soft-delete timestamp. Null = active. When set, the archetype is hidden from all lists (admin and public) and its detail page returns 404. See Soft-Delete Rules below. |
| `createdAt` | `DateTimeImmutable` | No | Creation timestamp. |
Expand Down
46 changes: 46 additions & 0 deletions migrations/Version20260529082719.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

/*
* This file is part of the Expanded Decks project.
*
* (c) Expanded Decks contributors
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

/**
* Add editor-defined OG image and description fields on Deck, ArchetypeTranslation,
* and PageTranslation so editors can override social-share metadata per locale.
*
* @see docs/features.md F18.30 — Editor-defined OG image and description on decks, archetypes, variants
* @see docs/features.md F18.31 — Editor-defined OG image and description on Banned & Staple Cards pages
*/
final class Version20260529082719 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add og_image/og_description columns to deck, archetype_translation, and page_translation.';
}

public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE deck ADD og_image VARCHAR(255) DEFAULT NULL, ADD og_description LONGTEXT DEFAULT NULL');
$this->addSql('ALTER TABLE archetype_translation ADD og_image VARCHAR(255) DEFAULT NULL, ADD og_description LONGTEXT DEFAULT NULL');
$this->addSql('ALTER TABLE page_translation ADD og_image VARCHAR(255) DEFAULT NULL, ADD og_description LONGTEXT DEFAULT NULL');
}

public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE deck DROP og_image, DROP og_description');
$this->addSql('ALTER TABLE archetype_translation DROP og_image, DROP og_description');
$this->addSql('ALTER TABLE page_translation DROP og_image, DROP og_description');
}
}
2 changes: 2 additions & 0 deletions src/Controller/AdminPageController.php
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,8 @@ public function duplicate(Request $request, Page $page, MenuRuntime $menuRuntime
$duplicateTranslation->setLocale($translation->getLocale());
$duplicateTranslation->setTitle($translation->getTitle().' (copy)');
$duplicateTranslation->setContent($translation->getContent());
$duplicateTranslation->setOgImage($translation->getOgImage());
$duplicateTranslation->setOgDescription($translation->getOgDescription());
$duplicate->addTranslation($duplicateTranslation);
}

Expand Down
Loading
Loading