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:
244
app/Filament/Pages/TranslationAssistant.php
Normal file
244
app/Filament/Pages/TranslationAssistant.php
Normal 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)),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
||||
use App\Filament\Resources\Calls\CallResource;
|
||||
use App\Filament\Support\DuplicateAction;
|
||||
use App\Filament\Support\PreviewAction;
|
||||
use App\Filament\Support\TranslateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
@@ -23,6 +24,7 @@ class EditCall extends EditRecord
|
||||
// Seeing it on the real site without leaving the form.
|
||||
PreviewAction::for('call'),
|
||||
DuplicateAction::make(),
|
||||
TranslateAction::make(),
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
||||
use App\Filament\Resources\Pages\PageResource;
|
||||
use App\Filament\Support\DuplicateAction;
|
||||
use App\Filament\Support\PreviewAction;
|
||||
use App\Filament\Support\TranslateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Actions\ForceDeleteAction;
|
||||
use Filament\Actions\RestoreAction;
|
||||
@@ -26,6 +27,7 @@ class EditPage extends EditRecord
|
||||
// Seeing it on the real site without leaving the form.
|
||||
PreviewAction::for('page'),
|
||||
DuplicateAction::make(),
|
||||
TranslateAction::make(),
|
||||
DeleteAction::make(),
|
||||
ForceDeleteAction::make(),
|
||||
RestoreAction::make(),
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
||||
use App\Filament\Resources\Projects\ProjectResource;
|
||||
use App\Filament\Support\DuplicateAction;
|
||||
use App\Filament\Support\PreviewAction;
|
||||
use App\Filament\Support\TranslateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
@@ -23,6 +24,7 @@ class EditProject extends EditRecord
|
||||
// Seeing it on the real site without leaving the form.
|
||||
PreviewAction::for('project'),
|
||||
DuplicateAction::make(),
|
||||
TranslateAction::make(),
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Filament\Concerns\WarnsOfConcurrentEditing;
|
||||
use App\Filament\Resources\Services\ServiceResource;
|
||||
use App\Filament\Support\DuplicateAction;
|
||||
use App\Filament\Support\PreviewAction;
|
||||
use App\Filament\Support\TranslateAction;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
@@ -23,6 +24,7 @@ class EditService extends EditRecord
|
||||
// Seeing it on the real site without leaving the form.
|
||||
PreviewAction::for('service'),
|
||||
DuplicateAction::make(),
|
||||
TranslateAction::make(),
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
|
||||
34
app/Filament/Support/TranslateAction.php
Normal file
34
app/Filament/Support/TranslateAction.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Filament\Support;
|
||||
|
||||
use App\Filament\Pages\TranslationAssistant;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Actions\ActionGroup;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
/**
|
||||
* "Translate into…": this record in the translation assistant, one entry per
|
||||
* language there is to translate into.
|
||||
*/
|
||||
final class TranslateAction
|
||||
{
|
||||
public static function make(): ActionGroup
|
||||
{
|
||||
$default = (string) config('locales.default');
|
||||
|
||||
return ActionGroup::make(collect(config('locales.supported'))
|
||||
->except($default)
|
||||
->map(fn (array $meta, string $locale) => Action::make("translate_{$locale}")
|
||||
->label($meta['native'] ?? $locale)
|
||||
->url(fn (Model $record) => TranslationAssistant::urlFor($record, $locale)))
|
||||
->values()
|
||||
->all())
|
||||
->label(__('panel.translate.action'))
|
||||
->icon('heroicon-o-language')
|
||||
->color('gray')
|
||||
->button();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user