Add a translation assistant to the panel

Each field's original sits beside the field to write, and a translation that
fell behind shows what the original said when it was translated and what it
says now, word by word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 10:53:31 +02:00
parent 76d022053b
commit 6fc600e6a1
16 changed files with 837 additions and 61 deletions

View File

@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace App\Filament\Pages;
use App\Support\ContentTypes;
use App\Support\PendingTranslations;
use App\Support\TextDiff;
use App\Support\TranslationFreshness;
use BackedEnum;
use Filament\Actions\Action;
use Filament\Forms\Components\RichEditor;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Pages\Page as FilamentPage;
use Filament\Schemas\Components\Grid;
use Filament\Schemas\Components\Section;
use Filament\Schemas\Components\Text;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
use Illuminate\Contracts\Support\Htmlable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\HtmlString;
use Livewire\Attributes\Url;
use UnitEnum;
/**
* Translating with the original beside you.
*
* Without a record: what is waiting to be translated, into which language.
* With one: each field's original next to the field to write, and for a
* translation that fell behind, what the original said when it was translated
* and what it says now — the sentence to redo rather than the whole text.
*/
class TranslationAssistant extends FilamentPage
{
/**
* Rich text, edited as such; everything else is plain.
*
* @var array<int, string>
*/
public const RICH = ['body', 'description', 'decisions', 'postmortem'];
/**
* Never translated here: an address is not a translation, and changing it
* has its own consequences on the record's own form.
*
* @var array<int, string>
*/
protected const SKIPPED = ['slug', 'featured_image_alt', 'cover_image_alt'];
protected string $view = 'filament.pages.translation-assistant';
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedLanguage;
protected static ?int $navigationSort = 7;
#[Url]
public ?string $type = null;
#[Url]
public ?string $record = null;
#[Url]
public ?string $locale = null;
public ?array $data = [];
public static function canAccess(): bool
{
return Auth::user()?->can('panel.access') ?? false;
}
public static function getNavigationGroup(): string|UnitEnum|null
{
return __('panel.groups.content');
}
public static function getNavigationLabel(): string
{
return __('panel.translate.title');
}
public static function urlFor(Model $record, ?string $locale = null): string
{
$type = array_search($record::class, PendingTranslations::TYPES, true);
return self::getUrl(array_filter(['type' => $type, 'record' => $record->getKey(), 'locale' => $locale]));
}
public function getTitle(): string|Htmlable
{
$record = $this->target();
return $record
? __('panel.translate.title_for', ['title' => ContentTypes::title($record), 'language' => $this->languageName($this->locale)])
: __('panel.translate.title');
}
public function mount(): void
{
$record = $this->target();
if ($record === null) {
return;
}
abort_unless(Auth::user()->can('update', $record), 403);
$this->form->fill(collect($this->fields($record))
->mapWithKeys(fn (string $field) => [$field => $record->getTranslation($field, $this->locale, false)])
->all());
}
/**
* The record being translated, when there is one and the language is one
* to translate into.
*/
public function target(): ?Model
{
$model = PendingTranslations::TYPES[$this->type] ?? null;
$locales = array_keys((array) config('locales.supported'));
if ($model === null || $this->record === null || ! in_array($this->locale, $locales, true) || $this->locale === config('locales.default')) {
return null;
}
return $model::query()->find($this->record);
}
/**
* @return Collection<int, array<string, mixed>>
*/
public function pending(): Collection
{
return PendingTranslations::find(Auth::user());
}
/**
* @return array<int, string>
*/
public function targetLocales(): array
{
return array_values(array_diff(array_keys((array) config('locales.supported')), [config('locales.default')]));
}
public function languageName(?string $locale): string
{
return (string) config("locales.supported.{$locale}.native", $locale);
}
public function form(Schema $schema): Schema
{
$record = $this->target();
if ($record === null) {
return $schema->statePath('data')->components([]);
}
$source = (string) config('locales.default');
$stale = TranslationFreshness::staleFields($record, $this->locale);
return $schema
->statePath('data')
->components(collect($this->fields($record))->map(function (string $field) use ($record, $source, $stale) {
$original = (string) $record->getTranslation($field, $source, false);
$rich = in_array($field, self::RICH, true);
$label = __("panel.translate.fields.{$field}");
$input = match (true) {
$rich => RichEditor::make($field)->hiddenLabel(),
mb_strlen($original) > 120 => Textarea::make($field)->hiddenLabel()->rows(4),
default => TextInput::make($field)->hiddenLabel(),
};
$diff = isset($stale[$field]) ? TextDiff::html($stale[$field]['was'], $original) : null;
return Section::make($label)
->description(isset($stale[$field])
? __('panel.translate.stale_since', ['date' => $stale[$field]['since']->isoFormat('LL')])
: null)
->icon(isset($stale[$field]) ? 'heroicon-o-exclamation-triangle' : null)
->iconColor('warning')
->compact()
->schema(array_filter([
$diff ? Text::make(new HtmlString('<div class="ta-diff"><strong>'.e(__('panel.translate.changed')).'</strong> '.$diff.'</div>')) : null,
Grid::make(['default' => 1, 'lg' => 2])->schema([
Text::make(new HtmlString(
'<div class="ta-source"><span class="ta-lang">'.e($this->languageName($source)).'</span>'
.($rich ? $original : nl2br(e($original))).'</div>'
)),
$input,
]),
]));
})->values()->all());
}
protected function getHeaderActions(): array
{
return $this->target() === null ? [] : [
Action::make('back')
->label(__('panel.translate.back'))
->color('gray')
->url(self::getUrl()),
];
}
public function save(): void
{
$record = $this->target();
abort_unless($record !== null && Auth::user()->can('update', $record), 403);
foreach ($this->form->getState() as $field => $value) {
if (in_array($field, $this->fields($record), true)) {
$record->setTranslation($field, $this->locale, (string) ($value ?? ''));
}
}
$record->save();
Notification::make()->title(__('panel.translate.saved'))->success()->send();
}
/**
* The fields worth translating on this record: translatable, not an
* address, and with something in the original to translate.
*
* @return array<int, string>
*/
protected function fields(Model $record): array
{
$source = (string) config('locales.default');
return array_values(array_filter(
array_diff($record->getTranslatableAttributes(), self::SKIPPED),
fn (string $field) => filled($record->getTranslation($field, $source, false)),
));
}
}