tangled
alpha
login
or
join now
vt3e.cat
/
bbell
11
fork
atom
wip bsky client for the web & android
bbell.vt3e.cat
11
fork
atom
overview
issues
pulls
pipelines
feat: alt textclear
vt3e.cat
1 month ago
05671021
92d3530b
verified
This commit was signed with the committer's
known signature
.
vt3e.cat
SSH Key Fingerprint:
SHA256:MaVgF6bXxDdD131G4rXizPh+sttp3IVsdPrj48HV0X0=
+238
-17
3 changed files
expand all
collapse all
unified
split
src
components
Composer
AltTextModal.vue
ComposerMedia.vue
composables
useComposer.ts
+128
src/components/Composer/AltTextModal.vue
···
1
1
+
<script setup lang="ts">
2
2
+
import { ref, watch } from 'vue'
3
3
+
import BaseModal from '@/components/UI/BaseModal.vue'
4
4
+
import BaseButton from '@/components/UI/BaseButton.vue'
5
5
+
import TextArea from '@/components/UI/TextArea.vue'
6
6
+
7
7
+
const props = defineProps<{
8
8
+
open: boolean
9
9
+
initialText: string
10
10
+
imageSrc: string
11
11
+
}>()
12
12
+
13
13
+
const emit = defineEmits<{
14
14
+
(e: 'close'): void
15
15
+
(e: 'save', text: string): void
16
16
+
}>()
17
17
+
18
18
+
const isOpen = ref(props.open)
19
19
+
const text = ref(props.initialText)
20
20
+
21
21
+
watch(
22
22
+
() => props.open,
23
23
+
(val) => {
24
24
+
isOpen.value = val
25
25
+
if (val) text.value = props.initialText
26
26
+
},
27
27
+
)
28
28
+
29
29
+
watch(isOpen, (val) => {
30
30
+
if (!val) emit('close')
31
31
+
})
32
32
+
33
33
+
function handleSave() {
34
34
+
emit('save', text.value)
35
35
+
isOpen.value = false
36
36
+
}
37
37
+
</script>
38
38
+
39
39
+
<template>
40
40
+
<BaseModal v-model:open="isOpen" title="Add Image Description" width="600px">
41
41
+
<div class="alt-editor">
42
42
+
<div class="image-preview">
43
43
+
<img :src="imageSrc" alt="Preview" />
44
44
+
</div>
45
45
+
<div class="input-area">
46
46
+
<p class="helper-text">
47
47
+
Alt text describes images for people with visual impairments. Good alt text is concise and
48
48
+
descriptive.
49
49
+
</p>
50
50
+
<TextArea
51
51
+
v-model="text"
52
52
+
placeholder="Describe this image..."
53
53
+
:rows="4"
54
54
+
autoresize
55
55
+
class="alt-input"
56
56
+
/>
57
57
+
</div>
58
58
+
</div>
59
59
+
60
60
+
<template #footer>
61
61
+
<BaseButton variant="ghost" @click="isOpen = false">Cancel</BaseButton>
62
62
+
<BaseButton variant="primary" @click="handleSave">Save</BaseButton>
63
63
+
</template>
64
64
+
</BaseModal>
65
65
+
</template>
66
66
+
67
67
+
<style scoped lang="scss">
68
68
+
.alt-editor {
69
69
+
display: flex;
70
70
+
flex-direction: column;
71
71
+
gap: 1rem;
72
72
+
73
73
+
@media (min-width: 768px) {
74
74
+
flex-direction: row;
75
75
+
align-items: flex-start;
76
76
+
}
77
77
+
}
78
78
+
79
79
+
.image-preview {
80
80
+
flex-shrink: 0;
81
81
+
width: 100%;
82
82
+
max-height: 200px;
83
83
+
border-radius: var(--radius-md);
84
84
+
overflow: hidden;
85
85
+
background: hsl(var(--surface0));
86
86
+
border: 1px solid hsla(var(--surface2) / 0.5);
87
87
+
display: flex;
88
88
+
align-items: center;
89
89
+
justify-content: center;
90
90
+
91
91
+
@media (min-width: 768px) {
92
92
+
width: 200px;
93
93
+
height: 200px;
94
94
+
}
95
95
+
96
96
+
img {
97
97
+
max-width: 100%;
98
98
+
max-height: 100%;
99
99
+
object-fit: contain;
100
100
+
}
101
101
+
}
102
102
+
103
103
+
.input-area {
104
104
+
flex: 1;
105
105
+
display: flex;
106
106
+
flex-direction: column;
107
107
+
gap: 0.5rem;
108
108
+
109
109
+
.helper-text {
110
110
+
font-size: 0.85rem;
111
111
+
color: hsl(var(--subtext0));
112
112
+
margin: 0;
113
113
+
}
114
114
+
115
115
+
.alt-input {
116
116
+
width: 100%;
117
117
+
font-size: 0.95rem;
118
118
+
119
119
+
:deep(textarea) {
120
120
+
padding: 0.5rem;
121
121
+
}
122
122
+
123
123
+
&:focus {
124
124
+
background: hsla(var(--surface1) / 0.5);
125
125
+
}
126
126
+
}
127
127
+
}
128
128
+
</style>
+81
-5
src/components/Composer/ComposerMedia.vue
···
1
1
<script setup lang="ts">
2
2
-
import { IconVideocamRounded, IconCloseRounded } from '@iconify-prerendered/vue-material-symbols'
2
2
+
import { computed, ref } from 'vue'
3
3
+
import {
4
4
+
IconVideocamRounded,
5
5
+
IconCloseRounded,
6
6
+
IconCheckRounded,
7
7
+
} from '@iconify-prerendered/vue-material-symbols'
3
8
import { useComposer } from '@/composables/useComposer'
9
9
+
import AltTextModal from './AltTextModal.vue'
4
10
5
5
-
defineProps<{
11
11
+
const props = defineProps<{
6
12
composer: ReturnType<typeof useComposer>
7
13
}>()
14
14
+
15
15
+
const showAltModal = ref(false)
16
16
+
const editingIndex = ref<number>(-1)
17
17
+
18
18
+
function openAltEditor(index: number) {
19
19
+
editingIndex.value = index
20
20
+
showAltModal.value = true
21
21
+
}
22
22
+
23
23
+
function handleSaveAlt(text: string) {
24
24
+
props.composer.updateImageAlt(editingIndex.value, text)
25
25
+
}
26
26
+
27
27
+
const image = computed(() => {
28
28
+
const tmp = props.composer.images.value[editingIndex.value]
29
29
+
if (!tmp) return { preview: '', alt: '' }
30
30
+
return tmp
31
31
+
})
8
32
</script>
9
33
10
34
<template>
···
25
49
</div>
26
50
</div>
27
51
28
28
-
<div v-else-if="composer.imagePreviews.value.length > 0" class="image-previews">
29
29
-
<div v-for="(src, idx) in composer.imagePreviews.value" :key="idx" class="preview-item">
30
30
-
<img :src="src" alt="preview" />
52
52
+
<div v-else-if="composer.images.value.length > 0" class="image-previews">
53
53
+
<div v-for="(img, idx) in composer.images.value" :key="idx" class="preview-item">
54
54
+
<img :src="img.preview" alt="preview" />
55
55
+
56
56
+
<button
57
57
+
class="alt-badge"
58
58
+
@click.stop="openAltEditor(idx)"
59
59
+
:class="{ 'has-alt': img.alt.length > 0 }"
60
60
+
:title="img.alt || 'Add image description'"
61
61
+
>
62
62
+
<IconCheckRounded v-if="img.alt.length > 0" />
63
63
+
ALT
64
64
+
</button>
65
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
75
+
76
76
+
<AltTextModal
77
77
+
v-if="editingIndex !== -1 && composer.images.value[editingIndex]"
78
78
+
:open="showAltModal"
79
79
+
:initial-text="image.alt"
80
80
+
:image-src="image.preview"
81
81
+
@close="showAltModal = false"
82
82
+
@save="handleSaveAlt"
83
83
+
/>
40
84
</div>
41
85
</template>
42
86
···
86
130
svg {
87
131
width: 0.875rem;
88
132
height: 0.875rem;
133
133
+
}
134
134
+
}
135
135
+
136
136
+
.alt-badge {
137
137
+
display: flex;
138
138
+
align-items: center;
139
139
+
justify-content: center;
140
140
+
141
141
+
position: absolute;
142
142
+
bottom: 0.5rem;
143
143
+
left: 0.5rem;
144
144
+
padding: 0.25rem 0.5rem;
145
145
+
font-size: 0.75rem;
146
146
+
font-weight: 700;
147
147
+
color: hsl(var(--text));
148
148
+
background: hsla(var(--surface0) / 0.8);
149
149
+
border-radius: var(--radius-sm);
150
150
+
border: none;
151
151
+
cursor: pointer;
152
152
+
153
153
+
svg {
154
154
+
width: 0.875rem;
155
155
+
height: 0.875rem;
156
156
+
}
157
157
+
158
158
+
&:hover {
159
159
+
background: hsla(var(--surface0) / 1);
160
160
+
}
161
161
+
162
162
+
&.has-alt {
163
163
+
background: hsl(var(--accent));
164
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
10
+
type ComposerImage = {
11
11
+
file: File
12
12
+
preview: string
13
13
+
alt: string
14
14
+
}
15
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
19
-
const images = ref<File[]>([])
20
20
-
const imagePreviews = ref<string[]>([])
25
25
+
const images = ref<ComposerImage[]>([])
26
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
91
-
const file = images.value[i]
97
97
+
const imgItem = images.value[i]
92
98
status.value = `Uploading image ${i + 1}/${images.value.length}...`
93
93
-
if (!file) throw new Error('No file selected')
99
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
99
-
input: await file.arrayBuffer(),
105
105
+
input: await imgItem.file.arrayBuffer(),
100
106
headers: {
101
101
-
'Content-Type': file.type,
107
107
+
'Content-Type': imgItem.file.type,
102
108
},
103
109
}),
104
110
)
105
111
106
112
blobs.push({
107
107
-
alt: '',
113
113
+
alt: imgItem.alt,
108
114
image: data.blob,
109
115
})
110
116
} catch (e) {
111
117
console.error('Blob upload failed', e)
112
112
-
throw new Error(`Failed to upload ${file.name}`)
118
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
284
-
images.value.push(file)
290
290
+
images.value.push({
291
291
+
file: file,
292
292
+
preview: URL.createObjectURL(file),
293
293
+
alt: '',
294
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
308
+
function updateImageAlt(index: number, alt: string) {
309
309
+
if (images.value[index]) {
310
310
+
images.value[index].alt = alt
311
311
+
}
312
312
+
}
313
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
309
-
imagePreviews.value.forEach((url) => URL.revokeObjectURL(url))
310
310
-
imagePreviews.value = []
325
325
+
images.value.forEach((img) => URL.revokeObjectURL(img.preview))
326
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
357
-
imagePreviews.value.forEach((url) => URL.revokeObjectURL(url))
373
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
394
+
updateImageAlt,
378
395
removeVideo,
379
396
reset,
380
397
validateFileSize,