wip bsky client for the web & android bbell.vt3e.cat

feat: alt textclear

vt3e.cat 05671021 92d3530b

verified
+238 -17
+128
src/components/Composer/AltTextModal.vue
··· 1 + <script setup lang="ts"> 2 + import { ref, watch } from 'vue' 3 + import BaseModal from '@/components/UI/BaseModal.vue' 4 + import BaseButton from '@/components/UI/BaseButton.vue' 5 + import TextArea from '@/components/UI/TextArea.vue' 6 + 7 + const props = defineProps<{ 8 + open: boolean 9 + initialText: string 10 + imageSrc: string 11 + }>() 12 + 13 + const emit = defineEmits<{ 14 + (e: 'close'): void 15 + (e: 'save', text: string): void 16 + }>() 17 + 18 + const isOpen = ref(props.open) 19 + const text = ref(props.initialText) 20 + 21 + watch( 22 + () => props.open, 23 + (val) => { 24 + isOpen.value = val 25 + if (val) text.value = props.initialText 26 + }, 27 + ) 28 + 29 + watch(isOpen, (val) => { 30 + if (!val) emit('close') 31 + }) 32 + 33 + function handleSave() { 34 + emit('save', text.value) 35 + isOpen.value = false 36 + } 37 + </script> 38 + 39 + <template> 40 + <BaseModal v-model:open="isOpen" title="Add Image Description" width="600px"> 41 + <div class="alt-editor"> 42 + <div class="image-preview"> 43 + <img :src="imageSrc" alt="Preview" /> 44 + </div> 45 + <div class="input-area"> 46 + <p class="helper-text"> 47 + Alt text describes images for people with visual impairments. Good alt text is concise and 48 + descriptive. 49 + </p> 50 + <TextArea 51 + v-model="text" 52 + placeholder="Describe this image..." 53 + :rows="4" 54 + autoresize 55 + class="alt-input" 56 + /> 57 + </div> 58 + </div> 59 + 60 + <template #footer> 61 + <BaseButton variant="ghost" @click="isOpen = false">Cancel</BaseButton> 62 + <BaseButton variant="primary" @click="handleSave">Save</BaseButton> 63 + </template> 64 + </BaseModal> 65 + </template> 66 + 67 + <style scoped lang="scss"> 68 + .alt-editor { 69 + display: flex; 70 + flex-direction: column; 71 + gap: 1rem; 72 + 73 + @media (min-width: 768px) { 74 + flex-direction: row; 75 + align-items: flex-start; 76 + } 77 + } 78 + 79 + .image-preview { 80 + flex-shrink: 0; 81 + width: 100%; 82 + max-height: 200px; 83 + border-radius: var(--radius-md); 84 + overflow: hidden; 85 + background: hsl(var(--surface0)); 86 + border: 1px solid hsla(var(--surface2) / 0.5); 87 + display: flex; 88 + align-items: center; 89 + justify-content: center; 90 + 91 + @media (min-width: 768px) { 92 + width: 200px; 93 + height: 200px; 94 + } 95 + 96 + img { 97 + max-width: 100%; 98 + max-height: 100%; 99 + object-fit: contain; 100 + } 101 + } 102 + 103 + .input-area { 104 + flex: 1; 105 + display: flex; 106 + flex-direction: column; 107 + gap: 0.5rem; 108 + 109 + .helper-text { 110 + font-size: 0.85rem; 111 + color: hsl(var(--subtext0)); 112 + margin: 0; 113 + } 114 + 115 + .alt-input { 116 + width: 100%; 117 + font-size: 0.95rem; 118 + 119 + :deep(textarea) { 120 + padding: 0.5rem; 121 + } 122 + 123 + &:focus { 124 + background: hsla(var(--surface1) / 0.5); 125 + } 126 + } 127 + } 128 + </style>
+81 -5
src/components/Composer/ComposerMedia.vue
··· 1 1 <script setup lang="ts"> 2 - import { IconVideocamRounded, IconCloseRounded } from '@iconify-prerendered/vue-material-symbols' 2 + import { computed, ref } from 'vue' 3 + import { 4 + IconVideocamRounded, 5 + IconCloseRounded, 6 + IconCheckRounded, 7 + } from '@iconify-prerendered/vue-material-symbols' 3 8 import { useComposer } from '@/composables/useComposer' 9 + import AltTextModal from './AltTextModal.vue' 4 10 5 - defineProps<{ 11 + const props = defineProps<{ 6 12 composer: ReturnType<typeof useComposer> 7 13 }>() 14 + 15 + const showAltModal = ref(false) 16 + const editingIndex = ref<number>(-1) 17 + 18 + function openAltEditor(index: number) { 19 + editingIndex.value = index 20 + showAltModal.value = true 21 + } 22 + 23 + function handleSaveAlt(text: string) { 24 + props.composer.updateImageAlt(editingIndex.value, text) 25 + } 26 + 27 + const image = computed(() => { 28 + const tmp = props.composer.images.value[editingIndex.value] 29 + if (!tmp) return { preview: '', alt: '' } 30 + return tmp 31 + }) 8 32 </script> 9 33 10 34 <template> ··· 25 49 </div> 26 50 </div> 27 51 28 - <div v-else-if="composer.imagePreviews.value.length > 0" class="image-previews"> 29 - <div v-for="(src, idx) in composer.imagePreviews.value" :key="idx" class="preview-item"> 30 - <img :src="src" alt="preview" /> 52 + <div v-else-if="composer.images.value.length > 0" class="image-previews"> 53 + <div v-for="(img, idx) in composer.images.value" :key="idx" class="preview-item"> 54 + <img :src="img.preview" alt="preview" /> 55 + 56 + <button 57 + class="alt-badge" 58 + @click.stop="openAltEditor(idx)" 59 + :class="{ 'has-alt': img.alt.length > 0 }" 60 + :title="img.alt || 'Add image description'" 61 + > 62 + <IconCheckRounded v-if="img.alt.length > 0" /> 63 + ALT 64 + </button> 65 + 31 66 <button 32 67 class="remove-btn" 33 68 @click.stop="composer.removeImage(idx)" ··· 37 72 </button> 38 73 </div> 39 74 </div> 75 + 76 + <AltTextModal 77 + v-if="editingIndex !== -1 && composer.images.value[editingIndex]" 78 + :open="showAltModal" 79 + :initial-text="image.alt" 80 + :image-src="image.preview" 81 + @close="showAltModal = false" 82 + @save="handleSaveAlt" 83 + /> 40 84 </div> 41 85 </template> 42 86 ··· 86 130 svg { 87 131 width: 0.875rem; 88 132 height: 0.875rem; 133 + } 134 + } 135 + 136 + .alt-badge { 137 + display: flex; 138 + align-items: center; 139 + justify-content: center; 140 + 141 + position: absolute; 142 + bottom: 0.5rem; 143 + left: 0.5rem; 144 + padding: 0.25rem 0.5rem; 145 + font-size: 0.75rem; 146 + font-weight: 700; 147 + color: hsl(var(--text)); 148 + background: hsla(var(--surface0) / 0.8); 149 + border-radius: var(--radius-sm); 150 + border: none; 151 + cursor: pointer; 152 + 153 + svg { 154 + width: 0.875rem; 155 + height: 0.875rem; 156 + } 157 + 158 + &:hover { 159 + background: hsla(var(--surface0) / 1); 160 + } 161 + 162 + &.has-alt { 163 + background: hsl(var(--accent)); 164 + color: hsl(var(--base)); 89 165 } 90 166 } 91 167
+29 -12
src/composables/useComposer.ts
··· 7 7 import { MAX_POST_IMAGE_SIZE, MAX_POST_VIDEO_SIZE, MAX_POST_TEXT_LENGTH } from '@/utils/constants' 8 8 import { formatSize } from '@/utils/formatting' 9 9 10 + type ComposerImage = { 11 + file: File 12 + preview: string 13 + alt: string 14 + } 15 + 10 16 export function useComposer() { 11 17 const auth = useAuthStore() 12 18 ··· 16 22 const errors = ref<string[]>([]) 17 23 18 24 // media state 19 - const images = ref<File[]>([]) 20 - const imagePreviews = ref<string[]>([]) 25 + const images = ref<ComposerImage[]>([]) 26 + const imagePreviews = computed(() => images.value.map((img) => img.preview)) 21 27 const video = ref<File | null>(null) 22 28 const videoPreview = ref<string | null>(null) 23 29 ··· 88 94 const blobs: AppBskyEmbedImages.Image[] = [] 89 95 90 96 for (let i = 0; i < images.value.length; i++) { 91 - const file = images.value[i] 97 + const imgItem = images.value[i] 92 98 status.value = `Uploading image ${i + 1}/${images.value.length}...` 93 - if (!file) throw new Error('No file selected') 99 + if (!imgItem?.file) throw new Error('No file selected') 94 100 95 101 try { 96 102 const rpc = auth.getRpc() 97 103 const data = ok( 98 104 await rpc.post('com.atproto.repo.uploadBlob', { 99 - input: await file.arrayBuffer(), 105 + input: await imgItem.file.arrayBuffer(), 100 106 headers: { 101 - 'Content-Type': file.type, 107 + 'Content-Type': imgItem.file.type, 102 108 }, 103 109 }), 104 110 ) 105 111 106 112 blobs.push({ 107 - alt: '', 113 + alt: imgItem.alt, 108 114 image: data.blob, 109 115 }) 110 116 } catch (e) { 111 117 console.error('Blob upload failed', e) 112 - throw new Error(`Failed to upload ${file.name}`) 118 + throw new Error(`Failed to upload ${imgItem.file.name}`) 113 119 } 114 120 } 115 121 ··· 281 287 } 282 288 283 289 for (const file of imageFiles) { 284 - images.value.push(file) 290 + images.value.push({ 291 + file: file, 292 + preview: URL.createObjectURL(file), 293 + alt: '', 294 + }) 285 295 imagePreviews.value.push(URL.createObjectURL(file)) 286 296 } 287 297 ··· 295 305 images.value.splice(index, 1) 296 306 } 297 307 308 + function updateImageAlt(index: number, alt: string) { 309 + if (images.value[index]) { 310 + images.value[index].alt = alt 311 + } 312 + } 313 + 298 314 function removeVideo() { 299 315 if (videoPreview.value) URL.revokeObjectURL(videoPreview.value) 300 316 video.value = null ··· 306 322 errors.value = [] 307 323 status.value = '' 308 324 images.value = [] 309 - imagePreviews.value.forEach((url) => URL.revokeObjectURL(url)) 310 - imagePreviews.value = [] 325 + images.value.forEach((img) => URL.revokeObjectURL(img.preview)) 326 + images.value = [] 311 327 if (videoPreview.value) URL.revokeObjectURL(videoPreview.value) 312 328 video.value = null 313 329 videoPreview.value = null ··· 354 370 } 355 371 356 372 onUnmounted(() => { 357 - imagePreviews.value.forEach((url) => URL.revokeObjectURL(url)) 373 + images.value.forEach((img) => URL.revokeObjectURL(img.preview)) 358 374 if (videoPreview.value) URL.revokeObjectURL(videoPreview.value) 359 375 }) 360 376 ··· 375 391 processMedia, 376 392 handleFileSelect, 377 393 removeImage, 394 + updateImageAlt, 378 395 removeVideo, 379 396 reset, 380 397 validateFileSize,