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.
79 lines
2.5 KiB
Vue
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>
|