trypost/resources/js/components/posts/editor/AltTextDialog.vue
Paulo Castellano cf045f2cdb Harden per-image alt text across publishers, validation, and attach paths
Publishing:
- Only send alt text for images (isImage guards on LinkedIn, X, Discord, Mastodon); never inject altText into video/document payloads.
- X sets alt via a best-effort media/metadata call so a metadata failure no longer blocks the tweet.

Validation:
- Validate media alt_text with a closure on media.*.meta so width/height/duration/slide_* survive a post update (Laravel's excludeUnvalidatedArrayKeys was stripping them).
- Add ALT_TEXT_MAX_LENGTH constant, a proper string-type error, and a localized attribute name.

Media attach (REST + MCP):
- Support per-image alt on attach-media-from-url via structured urls: [{url, alt?}] and on the MCP upload tool via an optional alt; alt is stored only for images.
- Carry submitted meta onto hosted external-URL media so alt is no longer dropped.

Composer:
- Alt-text dialog disables Save and reddens the counter over the limit, counting code points of the trimmed value to match the backend.
- Autosave shows 'Saved' only on a successful response; the lightbox alt overlay renders for images only.

Adds unit, feature, MCP, and browser tests covering every path above.
2026-07-16 13:55:33 -03:00

79 lines
2.5 KiB
Vue

<script setup lang="ts">
import { computed, 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 MAX_ALT_TEXT_LENGTH = 2000;
const props = defineProps<{
mediaItem: MediaItem | null;
}>();
const open = defineModel<boolean>('open', { required: true });
const emit = defineEmits<{
(e: 'save', value: string): void;
}>();
const value = ref('');
const length = computed(() => [...value.value.trim()].length);
const isOverLimit = computed(() => length.value > MAX_ALT_TEXT_LENGTH);
watch(open, (isOpen) => {
if (isOpen) {
value.value = props.mediaItem?.meta?.alt_text ?? '';
}
});
const save = () => {
if (isOverLimit.value) {
return;
}
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"
:class="isOverLimit ? 'text-destructive' : 'text-foreground/60'"
data-testid="alt-text-counter"
>
{{ length }} / {{ MAX_ALT_TEXT_LENGTH }}
</p>
</div>
<DialogFooter>
<Button data-testid="alt-text-save" :disabled="isOverLimit" @click="save">
{{ $t('posts.edit.alt_text.save') }}
</Button>
<Button variant="outline" @click="open = false">
{{ $t('posts.edit.cancel') }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>