Add per-image alt text editor to the post composer

This commit is contained in:
Paulo Castellano 2026-07-11 09:45:52 -03:00
parent f12f525d54
commit 8d7948cb2e
18 changed files with 249 additions and 1 deletions

View file

@ -378,6 +378,15 @@
'add_count' => 'إضافة :count',
],
'alt_text' => [
'label' => 'نص بديل',
'edit' => 'تحرير النص البديل',
'placeholder' => 'صف هذه الصورة للأشخاص الذين يستخدمون برامج قراءة الشاشة',
'hint' => 'يُنشر على المنصات التي تدعم أوصاف الصور.',
'badge' => 'ALT',
'save' => 'حفظ',
],
'emoji_picker' => [
'search' => 'البحث عن رمز تعبيري',
'empty' => 'لم يتم العثور على رموز تعبيرية',

View file

@ -380,6 +380,15 @@
'add_count' => ':count hinzufügen',
],
'alt_text' => [
'label' => 'Alternativtext',
'edit' => 'Alternativtext bearbeiten',
'placeholder' => 'Beschreibe dieses Bild für Menschen, die Screenreader verwenden',
'hint' => 'Wird auf Plattformen veröffentlicht, die Bildbeschreibungen unterstützen.',
'badge' => 'ALT',
'save' => 'Speichern',
],
'emoji_picker' => [
'search' => 'Emoji suchen',
'empty' => 'Keine Emojis gefunden',

View file

@ -378,6 +378,15 @@
'add_count' => 'Προσθήκη :count',
],
'alt_text' => [
'label' => 'Εναλλακτικό κείμενο',
'edit' => 'Επεξεργασία εναλλακτικού κειμένου',
'placeholder' => 'Περιγράψτε αυτήν την εικόνα για άτομα που χρησιμοποιούν προγράμματα ανάγνωσης οθόνης',
'hint' => 'Δημοσιεύεται σε πλατφόρμες που υποστηρίζουν περιγραφές εικόνων.',
'badge' => 'ALT',
'save' => 'Αποθήκευση',
],
'emoji_picker' => [
'search' => 'Αναζήτηση emoji',
'empty' => 'Δεν βρέθηκαν emoji',

View file

@ -378,6 +378,15 @@
'add_count' => 'Add :count',
],
'alt_text' => [
'label' => 'Alt text',
'edit' => 'Edit alt text',
'placeholder' => 'Describe this image for people using screen readers',
'hint' => 'Published to platforms that support image descriptions.',
'badge' => 'ALT',
'save' => 'Save',
],
'emoji_picker' => [
'search' => 'Search emoji',
'empty' => 'No emojis found',

View file

@ -378,6 +378,15 @@
'add_count' => 'Agregar :count',
],
'alt_text' => [
'label' => 'Texto alternativo',
'edit' => 'Editar texto alternativo',
'placeholder' => 'Describe esta imagen para las personas que usan lectores de pantalla',
'hint' => 'Se publica en las plataformas que admiten descripciones de imágenes.',
'badge' => 'ALT',
'save' => 'Guardar',
],
'emoji_picker' => [
'search' => 'Buscar emoji',
'empty' => 'No se encontraron emojis',

View file

@ -378,6 +378,15 @@
'add_count' => 'Ajouter :count',
],
'alt_text' => [
'label' => 'Texte alternatif',
'edit' => 'Modifier le texte alternatif',
'placeholder' => 'Décrivez cette image pour les personnes utilisant un lecteur d\'écran',
'hint' => 'Publié sur les plateformes qui prennent en charge les descriptions d\'images.',
'badge' => 'ALT',
'save' => 'Enregistrer',
],
'emoji_picker' => [
'search' => 'Rechercher un émoji',
'empty' => 'Aucun émoji trouvé',

View file

@ -378,6 +378,15 @@
'add_count' => 'Aggiungi :count',
],
'alt_text' => [
'label' => 'Testo alternativo',
'edit' => 'Modifica testo alternativo',
'placeholder' => 'Descrivi questa immagine per le persone che usano screen reader',
'hint' => 'Pubblicato sulle piattaforme che supportano le descrizioni delle immagini.',
'badge' => 'ALT',
'save' => 'Salva',
],
'emoji_picker' => [
'search' => 'Cerca emoji',
'empty' => 'Nessuna emoji trovata',

View file

@ -378,6 +378,15 @@
'add_count' => ':count 件を追加',
],
'alt_text' => [
'label' => '代替テキスト',
'edit' => '代替テキストを編集',
'placeholder' => 'スクリーンリーダーを使用する人のためにこの画像を説明してください',
'hint' => '画像の説明に対応しているプラットフォームに公開されます。',
'badge' => 'ALT',
'save' => '保存',
],
'emoji_picker' => [
'search' => '絵文字を検索',
'empty' => '絵文字が見つかりません',

View file

@ -378,6 +378,15 @@
'add_count' => ':count개 추가',
],
'alt_text' => [
'label' => '대체 텍스트',
'edit' => '대체 텍스트 편집',
'placeholder' => '화면 낭독 프로그램을 사용하는 사람들을 위해 이 이미지를 설명하세요',
'hint' => '이미지 설명을 지원하는 플랫폼에 게시됩니다.',
'badge' => 'ALT',
'save' => '저장',
],
'emoji_picker' => [
'search' => '이모지 검색',
'empty' => '이모지를 찾을 수 없습니다',

View file

@ -378,6 +378,15 @@
'add_count' => ':count toevoegen',
],
'alt_text' => [
'label' => 'Alt-tekst',
'edit' => 'Alt-tekst bewerken',
'placeholder' => 'Beschrijf deze afbeelding voor mensen die schermlezers gebruiken',
'hint' => 'Wordt gepubliceerd op platforms die afbeeldingsbeschrijvingen ondersteunen.',
'badge' => 'ALT',
'save' => 'Opslaan',
],
'emoji_picker' => [
'search' => 'Emoji zoeken',
'empty' => 'Geen emoji\'s gevonden',

View file

@ -378,6 +378,15 @@
'add_count' => 'Dodaj :count',
],
'alt_text' => [
'label' => 'Tekst alternatywny',
'edit' => 'Edytuj tekst alternatywny',
'placeholder' => 'Opisz ten obraz dla osób korzystających z czytników ekranu',
'hint' => 'Publikowany na platformach obsługujących opisy obrazów.',
'badge' => 'ALT',
'save' => 'Zapisz',
],
'emoji_picker' => [
'search' => 'Szukaj emoji',
'empty' => 'Nie znaleziono emoji',

View file

@ -378,6 +378,15 @@
'add_count' => 'Adicionar :count',
],
'alt_text' => [
'label' => 'Texto alternativo',
'edit' => 'Editar texto alternativo',
'placeholder' => 'Descreva esta imagem para pessoas que usam leitores de tela',
'hint' => 'Publicado nas plataformas que oferecem suporte a descrições de imagem.',
'badge' => 'ALT',
'save' => 'Salvar',
],
'emoji_picker' => [
'search' => 'Buscar emoji',
'empty' => 'Nenhum emoji encontrado',

View file

@ -378,6 +378,15 @@
'add_count' => 'Добавить :count',
],
'alt_text' => [
'label' => 'Альтернативный текст',
'edit' => 'Изменить альтернативный текст',
'placeholder' => 'Опишите это изображение для людей, использующих программы чтения с экрана',
'hint' => 'Публикуется на платформах, поддерживающих описания изображений.',
'badge' => 'ALT',
'save' => 'Сохранить',
],
'emoji_picker' => [
'search' => 'Поиск эмодзи',
'empty' => 'Эмодзи не найдены',

View file

@ -380,6 +380,15 @@
'add_count' => ':count tane ekle',
],
'alt_text' => [
'label' => 'Alternatif metin',
'edit' => 'Alternatif metni düzenle',
'placeholder' => 'Ekran okuyucu kullanan kişiler için bu görseli açıklayın',
'hint' => 'Görsel açıklamalarını destekleyen platformlarda yayınlanır.',
'badge' => 'ALT',
'save' => 'Kaydet',
],
'emoji_picker' => [
'search' => 'Emoji ara',
'empty' => 'Emoji bulunamadı',

View file

@ -378,6 +378,15 @@
'add_count' => '添加 :count 个',
],
'alt_text' => [
'label' => '替代文本',
'edit' => '编辑替代文本',
'placeholder' => '为使用屏幕阅读器的用户描述此图片',
'hint' => '发布到支持图片描述的平台。',
'badge' => 'ALT',
'save' => '保存',
],
'emoji_picker' => [
'search' => '搜索表情',
'empty' => '未找到表情',

View file

@ -0,0 +1,64 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { MediaItem } from '@/types/media';
const props = defineProps<{
mediaItem: MediaItem | null;
}>();
const open = defineModel<boolean>('open', { required: true });
const emit = defineEmits<{
(e: 'save', value: string): void;
}>();
const value = ref('');
watch(open, (isOpen) => {
if (isOpen) {
value.value = props.mediaItem?.meta?.alt_text ?? '';
}
});
const save = () => {
emit('save', value.value);
open.value = false;
};
</script>
<template>
<Dialog v-model:open="open">
<DialogContent class="sm:max-w-xl" data-testid="alt-text-dialog">
<DialogHeader>
<DialogTitle>{{ $t('posts.edit.alt_text.edit') }}</DialogTitle>
<DialogDescription>{{ $t('posts.edit.alt_text.hint') }}</DialogDescription>
</DialogHeader>
<div class="space-y-2">
<Label for="alt-text-input">{{ $t('posts.edit.alt_text.label') }}</Label>
<Textarea
id="alt-text-input"
v-model="value"
:placeholder="$t('posts.edit.alt_text.placeholder')"
rows="4"
data-testid="alt-text-input"
/>
<p class="text-right text-xs tabular-nums text-foreground/60">{{ value.length }}</p>
</div>
<DialogFooter>
<Button data-testid="alt-text-save" @click="save">
{{ $t('posts.edit.alt_text.save') }}
</Button>
<Button variant="outline" @click="open = false">
{{ $t('posts.edit.cancel') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import {
IconAlertTriangle,
IconAlt,
IconFileTypePdf,
IconGripVertical,
IconHash,
@ -16,6 +17,7 @@ import { trans } from 'laravel-vue-i18n';
import { computed, nextTick, ref } from 'vue';
import ImagePreviewDialog from '@/components/ImagePreviewDialog.vue';
import AltTextDialog from '@/components/posts/editor/AltTextDialog.vue';
import EmojiPicker from '@/components/posts/EmojiPicker.vue';
import MediaPickerDialog from '@/components/posts/MediaPickerDialog.vue';
import SignaturesModal from '@/components/posts/SignaturesModal.vue';
@ -24,7 +26,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { formatBytes } from '@/composables/useMedia';
import { getPlatformLabel, getPlatformLogo } from '@/composables/usePlatformLogo';
import date from '@/date';
import { classify, isDocument, isVideo, MediaType } from '@/lib/mediaType';
import { classify, isDocument, isImage, isVideo, MediaType } from '@/lib/mediaType';
import type { MediaItem } from '@/types/media';
interface Signature {
@ -205,6 +207,32 @@ const onMediaKeydown = async (event: KeyboardEvent, index: number) => {
const issueLabel = (reason: string): string => trans(`posts.form.warnings.${reason}`);
const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerate && item.source === 'ai';
const altDialogOpen = ref(false);
const altDialogIndex = ref<number | null>(null);
const altDialogItem = computed<MediaItem | null>(() =>
altDialogIndex.value !== null ? (media.value[altDialogIndex.value] ?? null) : null,
);
const openAltText = (index: number) => {
altDialogIndex.value = index;
altDialogOpen.value = true;
};
const setAltText = (index: number, alt: string): void => {
const next = [...media.value];
const trimmed = alt.trim();
next[index] = {
...next[index],
meta: { ...(next[index].meta ?? {}), alt_text: trimmed || undefined },
};
media.value = next;
};
const onAltTextSave = (value: string): void => {
if (altDialogIndex.value === null) return;
setAltText(altDialogIndex.value, value);
};
</script>
<template>
@ -269,6 +297,13 @@ const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerat
>
{{ formatBytes(item.size) }}
</span>
<span
v-if="isImage(item) && item.meta?.alt_text"
class="inline-flex rounded-md bg-black/65 px-1.5 py-0.5 font-bold tracking-wider backdrop-blur-sm"
data-testid="alt-text-badge"
>
{{ $t('posts.edit.alt_text.badge') }}
</span>
</div>
<TooltipProvider v-if="mediaIssues[item.id]" :delay-duration="100">
@ -308,6 +343,18 @@ const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerat
{{ $t('posts.ai.image_regenerate.button') }}
</button>
<button
v-if="!readOnly && isImage(item)"
type="button"
:title="$t('posts.edit.alt_text.edit')"
:aria-label="$t('posts.edit.alt_text.edit')"
class="absolute right-9 top-1.5 inline-flex size-6 cursor-pointer items-center justify-center rounded-md border-2 border-foreground bg-card text-foreground opacity-0 shadow-2xs transition-all hover:bg-violet-100 group-hover:opacity-100 group-focus:opacity-100"
data-testid="alt-text-button"
@click.stop="openAltText(index)"
>
<IconAlt class="size-3.5" />
</button>
<button
v-if="!readOnly"
type="button"
@ -449,5 +496,6 @@ const canRegenerateWithAi = (item: MediaItem): boolean => props.allowAiRegenerat
<SignaturesModal ref="signaturesModal" :signatures="signatures" @select="appendSignature" />
<MediaPickerDialog ref="mediaPickerDialog" @select="addMediaFromGallery" />
<ImagePreviewDialog ref="lightbox" />
<AltTextDialog v-model:open="altDialogOpen" :media-item="altDialogItem" @save="onAltTextSave" />
</div>
</template>

View file

@ -16,5 +16,6 @@ export interface MediaItem {
width?: number;
height?: number;
duration?: number;
alt_text?: string;
};
}