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

@@ -941,6 +941,22 @@ en tres idiomas: si llegaron juntos, ninguno va por detrás de otro.
El cálculo se cachea contra el `updated_at` del registro, así que se rehace en
cuanto algo cambia y ni un minuto antes.
### El asistente de traducción
Contenido → Traducciones lista lo que falta escribir en un idioma (azul) y lo
que se quedó atrás (ámbar, con desde cuándo), con un botón por idioma. Desde la
edición de una página, proyecto, servicio o convocatoria, «Traducir» lleva al
mismo sitio.
Al abrir uno, cada campo traducible sale **con el original al lado** y el campo
a escribir enfrente (texto enriquecido donde lo es). Y si la traducción se quedó
atrás, ese campo dice desde cuándo y enseña **qué cambió en el original desde
que se tradujo**, palabra a palabra: lo que se quitó tachado y lo que entró
marcado. El texto «de entonces» sale del historial, como la fecha. Así quien
traduce rehace la frase que cambió en vez de releerlo todo. Solo se guarda el
idioma que se está traduciendo, y el slug se deja al formulario de la ficha:
una dirección no es una traducción.
## Revisión
`pages.publish` lleva separado desde el principio: **poner algo en la web es un

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)),
));
}
}

View File

@@ -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(),
];
}

View File

@@ -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(),

View File

@@ -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(),
];
}

View File

@@ -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(),
];
}

View 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();
}
}

View File

@@ -5,16 +5,9 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Call;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Support\AdminContent;
use App\Support\ContentTypes;
use App\Support\Health\Finding;
use App\Support\Health\HealthReport;
use App\Support\TranslationFreshness;
use Illuminate\Database\Eloquent\Model;
use App\Support\PendingTranslations;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
@@ -25,13 +18,6 @@ use Illuminate\Validation\Rule;
*/
class HealthController extends Controller
{
/**
* The kinds of content written in several languages and checked for it.
*
* @var array<int, class-string<Model>>
*/
protected const TRANSLATED = [Page::class, Project::class, Service::class, Call::class];
/**
* Every check the caller may see, problems first. A check behind a
* permission they lack is left out, as on the panel.
@@ -77,52 +63,18 @@ class HealthController extends Controller
'published' => ['sometimes', 'boolean'],
]);
$wanted = $request->input('locale');
$items = collect();
foreach (self::TRANSLATED as $model) {
$type = AdminContent::nameOf(new $model);
if ($request->filled('type') && $request->input('type') !== $type) {
continue;
}
if ($request->user()->cannot('viewAny', $model)) {
continue;
}
$model::query()
->when($request->boolean('published'), fn ($q) => $model === Call::class ? $q->open() : $q->published())
->with('revisions')
->get()
->each(function (Model $record) use ($locales, $wanted, $type, $items): void {
$missing = array_values(array_filter(
$locales,
fn (string $locale) => blank($record->getTranslation('title', $locale, false)),
));
$stale = collect(TranslationFreshness::for($record))
->map(fn ($since) => $since->toIso8601String())
->all();
if ($wanted !== null) {
$missing = array_values(array_intersect($missing, [$wanted]));
$stale = array_intersect_key($stale, [$wanted => true]);
}
if ($missing === [] && $stale === []) {
return;
}
$items->push([
'type' => $type,
'id' => $record->getKey(),
'title' => ContentTypes::title($record),
'missing' => $missing,
'stale' => (object) $stale,
]);
});
}
$items = PendingTranslations::find(
$request->user(),
$request->input('locale'),
$request->input('type'),
$request->boolean('published'),
)->map(fn (array $item) => [
'type' => $item['type'],
'id' => $item['id'],
'title' => $item['title'],
'missing' => $item['missing'],
'stale' => (object) collect($item['stale'])->map(fn ($since) => $since->toIso8601String())->all(),
]);
return response()->json(['data' => $items->values()]);
}

View File

@@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace App\Support;
use App\Models\Call;
use App\Models\Page;
use App\Models\Project;
use App\Models\Service;
use App\Models\User;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Collection;
/**
* Records with a language still to write (`missing`) or left behind by the
* one it was translated from (`stale`, with since when) — for the panel's
* translation assistant and the API alike.
*/
final class PendingTranslations
{
/**
* @var array<string, class-string<Model>>
*/
public const TYPES = [
'pages' => Page::class,
'projects' => Project::class,
'services' => Service::class,
'calls' => Call::class,
];
/**
* @return Collection<int, array{type: string, id: int|string, record: Model, title: string, missing: array<int, string>, stale: array<string, CarbonInterface>}>
*/
public static function find(User $user, ?string $locale = null, ?string $type = null, bool $publishedOnly = false): Collection
{
$locales = array_keys((array) config('locales.supported'));
$source = (string) config('locales.default');
$items = collect();
foreach (self::TYPES as $name => $model) {
if (($type !== null && $type !== $name) || $user->cannot('viewAny', $model)) {
continue;
}
$model::query()
->when($publishedOnly, fn ($q) => $model === Call::class ? $q->open() : $q->published())
->with('revisions')
->get()
->each(function (Model $record) use ($locales, $source, $locale, $name, $items): void {
$missing = array_values(array_filter(
$locales,
fn (string $l) => $l !== $source && blank($record->getTranslation('title', $l, false)),
));
$stale = TranslationFreshness::for($record);
if ($locale !== null) {
$missing = array_values(array_intersect($missing, [$locale]));
$stale = array_intersect_key($stale, [$locale => true]);
}
if ($missing === [] && $stale === []) {
return;
}
$items->push([
'type' => $name,
'id' => $record->getKey(),
'record' => $record,
'title' => ContentTypes::title($record),
'missing' => $missing,
'stale' => $stale,
]);
});
}
return $items->values();
}
}

99
app/Support/TextDiff.php Normal file
View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Support;
/**
* What changed between two versions of a text, word by word, as HTML with
* `<del>` and `<ins>` — enough to show a translator which sentence of the
* original to look at again.
*/
final class TextDiff
{
/**
* Above this many words on either side the comparison is skipped: it is
* quadratic, and a text that long changed is better reread whole.
*/
protected const MAX_WORDS = 1500;
public static function html(?string $old, ?string $new): ?string
{
$a = self::words($old);
$b = self::words($new);
if (count($a) > self::MAX_WORDS || count($b) > self::MAX_WORDS) {
return null;
}
// Longest common subsequence, then walked back into runs.
$m = count($a);
$n = count($b);
$lcs = array_fill(0, $m + 1, array_fill(0, $n + 1, 0));
for ($i = $m - 1; $i >= 0; $i--) {
for ($j = $n - 1; $j >= 0; $j--) {
$lcs[$i][$j] = $a[$i] === $b[$j] ? $lcs[$i + 1][$j + 1] + 1 : max($lcs[$i + 1][$j], $lcs[$i][$j + 1]);
}
}
$out = [];
$i = $j = 0;
while ($i < $m || $j < $n) {
if ($i < $m && $j < $n && $a[$i] === $b[$j]) {
$out[] = ['=', $a[$i]];
$i++;
$j++;
} elseif ($i < $m && ($j === $n || $lcs[$i + 1][$j] >= $lcs[$i][$j + 1])) {
// What went comes before what replaced it.
$out[] = ['-', $a[$i]];
$i++;
} else {
$out[] = ['+', $b[$j]];
$j++;
}
}
$html = '';
$run = null;
$buffer = [];
$flush = function () use (&$html, &$run, &$buffer): void {
if ($buffer === []) {
return;
}
$text = e(implode(' ', $buffer));
$html .= match ($run) {
'+' => "<ins>{$text}</ins> ",
'-' => "<del>{$text}</del> ",
default => "{$text} ",
};
$buffer = [];
};
foreach ($out as [$op, $word]) {
if ($op !== $run) {
$flush();
$run = $op;
}
$buffer[] = $word;
}
$flush();
return trim($html);
}
/**
* @return array<int, string>
*/
protected static function words(?string $text): array
{
$plain = trim((string) preg_replace('/\s+/u', ' ', html_entity_decode(strip_tags(str_replace(['</p>', '<br>', '<br/>', '<br />'], ' ', (string) $text)))));
return $plain === '' ? [] : explode(' ', $plain);
}
}

View File

@@ -71,6 +71,69 @@ final class TranslationFreshness
/**
* @return array<string, CarbonInterface>
*/
/**
* Field by field, for one language: which fields the original changed
* after this language was last written, since when, and what the
* original said back then — so a translator can see what to redo rather
* than rereading the whole text.
*
* @return array<string, array{since: CarbonInterface, was: ?string}>
*/
public static function staleFields(Model $record, string $locale): array
{
$source = (string) config('locales.default');
$fields = self::fields($record);
$touched = self::lastTouched($record, $fields);
$stale = [];
foreach ($fields as $field) {
if ($locale === $source || blank($record->getTranslation($field, $locale, false))) {
continue;
}
$sourceAt = $touched[$field][$source] ?? null;
$translatedAt = $touched[$field][$locale] ?? $record->created_at;
if ($sourceAt !== null && $translatedAt !== null && $sourceAt->gt($translatedAt)) {
$stale[$field] = [
'since' => $sourceAt,
'was' => self::sourceAsOf($record, $field, $translatedAt),
];
}
}
return $stale;
}
/**
* The original's text in a field as it stood at a moment, from the
* history: the last value written at or before it, or the value the first
* later change replaced.
*/
protected static function sourceAsOf(Model $record, string $field, CarbonInterface $at): ?string
{
$source = (string) config('locales.default');
$value = null;
$found = false;
foreach ($record->revisions()->reorder()->orderBy('id')->get() as $revision) {
$change = $revision->changeset[$field] ?? null;
if (! is_array($change)) {
continue;
}
if ($revision->created_at->lte($at)) {
$value = ((array) ($change['new'] ?? []))[$source] ?? null;
$found = true;
} elseif (! $found) {
return ((array) ($change['old'] ?? []))[$source] ?? null;
}
}
return $found ? $value : $record->getTranslation($field, $source, false);
}
protected static function compute(Model $record): array
{
$source = (string) config('locales.default');

View File

@@ -144,6 +144,38 @@ return [
'select_all' => 'Selecciona-ho tot',
],
'translate' => [
'title' => 'Traduccions',
'title_for' => ':title en :language',
'action' => 'Traduir',
'pending' => 'Per traduir',
'pending_hint' => 'El que falta escriure en un idioma (blau) o s\'ha quedat enrere perquè l\'original va canviar després (ambre).',
'nothing' => 'Tot està traduït i al dia.',
'translate_into' => 'Traduir al :language',
'update_in' => 'Actualitzar :language (des de :since)',
'changed' => 'Ha canviat a l\'original:',
'stale_since' => 'L\'original va canviar el :date, després de traduir-se.',
'save' => 'Desar en :language',
'saved' => 'Traducció desada',
'back' => 'Tornar a la llista',
'columns' => [
'record' => 'Contingut',
'languages' => 'Idiomes',
],
'fields' => [
'title' => 'Títol',
'excerpt' => 'Extracte',
'body' => 'Contingut',
'meta_title' => 'Títol SEO',
'meta_description' => 'Descripció SEO',
'tagline' => 'Lema',
'description' => 'Descripció',
'decisions' => 'Decisions',
'postmortem' => 'Postmortem',
'what_you_get' => 'Què t\'emportes',
],
],
'replies' => [
'empty_template' => 'La plantilla no té text en l\'idioma d\'aquesta sol·licitud.',
'heading' => 'Respondre a :name',

View File

@@ -144,6 +144,38 @@ return [
'select_all' => 'Select all',
],
'translate' => [
'title' => 'Translations',
'title_for' => ':title in :language',
'action' => 'Translate',
'pending' => 'To translate',
'pending_hint' => 'What is still to be written in a language (blue) or fell behind because the original changed afterwards (amber).',
'nothing' => 'Everything is translated and up to date.',
'translate_into' => 'Translate into :language',
'update_in' => 'Update :language (since :since)',
'changed' => 'Changed in the original:',
'stale_since' => 'The original changed on :date, after it was translated.',
'save' => 'Save in :language',
'saved' => 'Translation saved',
'back' => 'Back to the list',
'columns' => [
'record' => 'Content',
'languages' => 'Languages',
],
'fields' => [
'title' => 'Title',
'excerpt' => 'Excerpt',
'body' => 'Content',
'meta_title' => 'SEO title',
'meta_description' => 'SEO description',
'tagline' => 'Tagline',
'description' => 'Description',
'decisions' => 'Decisions',
'postmortem' => 'Postmortem',
'what_you_get' => 'What you get',
],
],
'replies' => [
'empty_template' => 'The template has no text in the language of this submission.',
'heading' => 'Reply to :name',

View File

@@ -144,6 +144,38 @@ return [
'select_all' => 'Seleccionar todos',
],
'translate' => [
'title' => 'Traducciones',
'title_for' => ':title en :language',
'action' => 'Traducir',
'pending' => 'Por traducir',
'pending_hint' => 'Lo que falta escribir en un idioma (azul) o se quedó atrás porque el original cambió después (ámbar).',
'nothing' => 'Todo está traducido y al día.',
'translate_into' => 'Traducir al :language',
'update_in' => 'Actualizar :language (desde :since)',
'changed' => 'Cambió en el original:',
'stale_since' => 'El original cambió el :date, después de traducirse.',
'save' => 'Guardar en :language',
'saved' => 'Traducción guardada',
'back' => 'Volver a la lista',
'columns' => [
'record' => 'Contenido',
'languages' => 'Idiomas',
],
'fields' => [
'title' => 'Título',
'excerpt' => 'Extracto',
'body' => 'Contenido',
'meta_title' => 'Título SEO',
'meta_description' => 'Descripción SEO',
'tagline' => 'Lema',
'description' => 'Descripción',
'decisions' => 'Decisiones',
'postmortem' => 'Postmortem',
'what_you_get' => 'Qué te llevas',
],
],
'replies' => [
'empty_template' => 'La plantilla no tiene texto en el idioma de esta solicitud.',
'heading' => 'Responder a :name',

View File

@@ -0,0 +1,71 @@
<x-filament-panels::page>
<style>
.ta-source { padding: .5rem .75rem; border-radius: .5rem; background: color-mix(in oklab, currentColor 5%, transparent); font-size: .875rem; line-height: 1.5; }
.ta-source p { margin: 0 0 .5rem; }
.ta-lang { display: block; font-size: .7rem; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; opacity: .6; margin-bottom: .25rem; }
.ta-diff { font-size: .875rem; line-height: 1.6; padding: .5rem .75rem; border-left: 3px solid #f59e0b; background: color-mix(in oklab, #f59e0b 8%, transparent); border-radius: .25rem; }
.ta-diff del { background: color-mix(in oklab, #dc2626 18%, transparent); text-decoration: line-through; }
.ta-diff ins { background: color-mix(in oklab, #16a34a 20%, transparent); text-decoration: none; }
.ta-table { width: 100%; border-collapse: collapse; font-size: .875rem; }
.ta-table th { text-align: left; font-size: .75rem; text-transform: uppercase; opacity: .7; padding: .5rem; }
.ta-table td { padding: .5rem; border-top: 1px solid color-mix(in oklab, currentColor 10%, transparent); vertical-align: top; }
.ta-actions { display: flex; flex-wrap: wrap; gap: .5rem; }
</style>
@if ($this->target())
<form wire:submit="save" style="display: grid; gap: 1.5rem;">
{{ $this->form }}
<div>
<x-filament::button type="submit">
{{ __('panel.translate.save', ['language' => $this->languageName($this->locale)]) }}
</x-filament::button>
</div>
</form>
@else
@php($pending = $this->pending())
<x-filament::section :heading="__('panel.translate.pending')" :description="__('panel.translate.pending_hint')">
@if ($pending->isEmpty())
<p>{{ __('panel.translate.nothing') }}</p>
@else
<table class="ta-table">
<thead>
<tr>
<th scope="col">{{ __('panel.translate.columns.record') }}</th>
<th scope="col">{{ __('panel.translate.columns.languages') }}</th>
</tr>
</thead>
<tbody>
@foreach ($pending as $item)
<tr>
<td>
<x-filament::badge color="gray" size="sm" style="display: inline-flex">{{ __('panel.statistics.types.'.$item['type']) }}</x-filament::badge>
{{ $item['title'] }}
</td>
<td>
<div class="ta-actions">
@foreach ($this->targetLocales() as $locale)
@if (in_array($locale, $item['missing'], true) || array_key_exists($locale, $item['stale']))
<x-filament::button
size="xs"
:color="in_array($locale, $item['missing'], true) ? 'primary' : 'warning'"
tag="a"
:href="\App\Filament\Pages\TranslationAssistant::urlFor($item['record'], $locale)"
>
{{ in_array($locale, $item['missing'], true)
? __('panel.translate.translate_into', ['language' => $this->languageName($locale)])
: __('panel.translate.update_in', ['language' => $this->languageName($locale), 'since' => $item['stale'][$locale]->diffForHumans()]) }}
</x-filament::button>
@endif
@endforeach
</div>
</td>
</tr>
@endforeach
</tbody>
</table>
@endif
</x-filament::section>
@endif
</x-filament-panels::page>

View File

@@ -0,0 +1,112 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Panel;
use App\Filament\Pages\TranslationAssistant;
use App\Models\Page;
use App\Support\TextDiff;
use App\Support\TranslationFreshness;
use Database\Seeders\RolePermissionSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use PHPUnit\Framework\Attributes\Test;
use Tests\FakesPireon;
use Tests\TestCase;
class TranslationAssistantTest extends TestCase
{
use FakesPireon, RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(RolePermissionSeeder::class);
}
#[Test]
public function the_list_offers_each_missing_language(): void
{
$this->actingAs($this->userWithRoles('editor'));
Page::factory()->draft()->create(['title' => ['es' => 'Solo en castellano'], 'slug' => ['es' => 'solo']]);
Livewire::test(TranslationAssistant::class)
->assertSee('Solo en castellano')
->assertSee('Traducir al English');
}
#[Test]
public function a_translation_is_written_beside_the_original(): void
{
$this->actingAs($this->userWithRoles('editor'));
$page = Page::factory()->draft()->create([
'title' => ['es' => 'Hola'],
'slug' => ['es' => 'hola'],
'excerpt' => ['es' => 'Un extracto.'],
'body' => ['es' => '<p>Cuerpo.</p>'],
'meta_title' => null,
'meta_description' => null,
]);
Livewire::withQueryParams(['type' => 'pages', 'record' => $page->id, 'locale' => 'en'])
->test(TranslationAssistant::class)
->assertSee('Un extracto.')
->fillForm(['title' => 'Hello', 'excerpt' => 'An excerpt.'])
->call('save')
->assertHasNoFormErrors();
$page->refresh();
$this->assertSame('Hello', $page->getTranslation('title', 'en'));
$this->assertSame('An excerpt.', $page->getTranslation('excerpt', 'en'));
// The original is untouched.
$this->assertSame('Hola', $page->getTranslation('title', 'es'));
}
#[Test]
public function a_translation_left_behind_shows_what_changed_in_the_original(): void
{
$this->actingAs($this->userWithRoles('editor'));
$this->travelTo('2026-09-01 10:00');
$page = Page::factory()->draft()->create([
'title' => ['es' => 'Hola', 'en' => 'Hello'],
'slug' => ['es' => 'hola', 'en' => 'hello'],
'excerpt' => ['es' => 'Hacemos webs pequeñas.', 'en' => 'We make small websites.'],
]);
$this->travelTo('2026-09-10 10:00');
$page->update(['excerpt' => ['es' => 'Hacemos webs pequeñas y rápidas.', 'en' => 'We make small websites.']]);
$stale = TranslationFreshness::staleFields($page->fresh(), 'en');
$this->assertSame(['excerpt'], array_keys($stale));
$this->assertSame('Hacemos webs pequeñas.', $stale['excerpt']['was']);
Livewire::withQueryParams(['type' => 'pages', 'record' => $page->id, 'locale' => 'en'])
->test(TranslationAssistant::class)
->assertSeeHtml('<del>pequeñas.</del> <ins>pequeñas y rápidas.</ins>');
}
#[Test]
public function the_diff_marks_words_in_and_out(): void
{
$this->assertSame('Hacemos webs <del>pequeñas.</del> <ins>rápidas.</ins>', TextDiff::html('Hacemos webs pequeñas.', 'Hacemos webs rápidas.'));
}
#[Test]
public function only_who_may_edit_it_may_translate_it(): void
{
$this->actingAs($this->userWithRoles('viewer'));
$page = Page::factory()->create();
Livewire::withQueryParams(['type' => 'pages', 'record' => $page->id, 'locale' => 'en'])
->test(TranslationAssistant::class)
->assertForbidden();
}
}