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

feat,refactor: extract posting logic to a composable; new post composer

vt3e.cat fe87f236 a062e32c

verified
+1479 -1093
+43 -2
src/App.vue
··· 11 11 import NavigationBar from '@/components/Navigation/NavigationBar.vue' 12 12 import OAuthCallback from '@/views/Auth/OAuthCallback.vue' 13 13 import OnboardingFlow from '@/views/Onboarding/OnboardingFlow.vue' 14 + import PostComposer from '@/components/Composer/PostComposer.vue' 14 15 15 16 import { stackRoots, type StackRootNames } from './router' 17 + import BaseModal from './components/UI/BaseModal.vue' 16 18 17 19 const nav = useNavigationStore() 18 20 const env = useEnvironmentStore() ··· 26 28 27 29 const hasSeenIntro = localStorage.getItem('bluebell-intro-complete') === 'true' 28 30 const showIntro = ref(!hasSeenIntro && !isCallback.value) 31 + 32 + const modalOpen = computed(() => showPostComposerDialog.value) 33 + const showPostComposerDialog = ref(false) 29 34 30 35 auth.init() 31 36 ··· 60 65 nav.pop() 61 66 } 62 67 68 + function isTypingInInput(): boolean { 69 + const active = document.activeElement as HTMLElement | null 70 + if (!active) return false 71 + return ( 72 + active.tagName === 'INPUT' || 73 + active.tagName === 'TEXTAREA' || 74 + active.tagName === 'SELECT' || 75 + active.isContentEditable 76 + ) 77 + } 78 + function handleKeybings(e: KeyboardEvent) { 79 + if (isTypingInInput()) return 80 + 81 + if (!e.ctrlKey) { 82 + switch (e.key) { 83 + case 'c': 84 + showPostComposerDialog.value = true 85 + break 86 + case 'Escape': 87 + showPostComposerDialog.value = false 88 + break 89 + } 90 + } 91 + } 92 + 63 93 onMounted(async () => { 64 94 App.addListener('backButton', handleBackNavigation) 65 95 document.addEventListener('keyup', (e) => { ··· 80 110 } 81 111 }) 82 112 113 + window.addEventListener('keyup', handleKeybings) 114 + 83 115 theme.init() 84 116 env.init() 85 117 ··· 90 122 91 123 onUnmounted(() => { 92 124 App.removeAllListeners() 93 - document.removeEventListener('keyup', () => {}) 125 + window.removeEventListener('keyup', handleKeybings) 94 126 }) 95 127 </script> 96 128 ··· 100 132 </Transition> 101 133 102 134 <Transition name="app-fade" mode="in-out"> 103 - <div v-if="!showIntro" class="app-root"> 135 + <div v-if="!showIntro" class="app-root" :inert="modalOpen"> 104 136 <Transition name="app-fade" mode="out-in"> 105 137 <OAuthCallback 106 138 v-if="isCallback" ··· 130 162 </Transition> 131 163 </div> 132 164 </Transition> 165 + 166 + <BaseModal 167 + title="New Post" 168 + :open="showPostComposerDialog" 169 + width="600px" 170 + @close="showPostComposerDialog = false" 171 + > 172 + <PostComposer @close="showPostComposerDialog = false" /> 173 + </BaseModal> 133 174 </template> 134 175 135 176 <style scoped>
+1
src/assets/main.css
··· 22 22 outline-offset: 4px; 23 23 24 24 transition: 25 + margin var(--transition), 25 26 grid-template-rows var(--transition), 26 27 grid-template-columns var(--transition), 27 28 stroke-dasharray var(--transition),
+43
src/components/Composer/ComposerErrors.vue
··· 1 + <script lang="ts" setup> 2 + import { IconErrorRounded } from '@iconify-prerendered/vue-material-symbols' 3 + import { useComposer } from '@/composables/useComposer' 4 + 5 + defineProps<{ 6 + composer: ReturnType<typeof useComposer> 7 + }>() 8 + </script> 9 + 10 + <template> 11 + <div v-if="composer.errors.value.length > 0" class="error-container"> 12 + <div v-for="(err, idx) in composer.errors.value" :key="idx" class="error-item"> 13 + <IconErrorRounded /> 14 + <span>{{ err }}</span> 15 + </div> 16 + </div> 17 + </template> 18 + 19 + <style scoped> 20 + .error-container { 21 + margin-top: 0.5rem; 22 + display: flex; 23 + flex-direction: column; 24 + gap: 0.25rem; 25 + 26 + .error-item { 27 + display: flex; 28 + align-items: center; 29 + gap: 0.5rem; 30 + font-size: 0.85rem; 31 + color: hsl(var(--red)); 32 + background-color: hsla(var(--red) / 0.1); 33 + padding: 0.5rem; 34 + border-radius: var(--radius-sm); 35 + 36 + svg { 37 + flex-shrink: 0; 38 + width: 1rem; 39 + height: 1rem; 40 + } 41 + } 42 + } 43 + </style>
+122
src/components/Composer/ComposerMedia.vue
··· 1 + <script setup lang="ts"> 2 + import { IconVideocamRounded, IconCloseRounded } from '@iconify-prerendered/vue-material-symbols' 3 + import { useComposer } from '@/composables/useComposer' 4 + 5 + defineProps<{ 6 + composer: ReturnType<typeof useComposer> 7 + }>() 8 + </script> 9 + 10 + <template> 11 + <div class="media-container"> 12 + <div v-if="composer.videoPreview.value" class="video-preview"> 13 + <div class="preview-item video-item"> 14 + <video :src="composer.videoPreview.value" muted preload="metadata" /> 15 + <div class="video-badge"> 16 + <IconVideocamRounded /> 17 + </div> 18 + <button 19 + class="remove-btn" 20 + @click.stop="composer.removeVideo" 21 + :disabled="composer.loading.value" 22 + > 23 + <IconCloseRounded /> 24 + </button> 25 + </div> 26 + </div> 27 + 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" /> 31 + <button 32 + class="remove-btn" 33 + @click.stop="composer.removeImage(idx)" 34 + :disabled="composer.loading.value" 35 + > 36 + <IconCloseRounded /> 37 + </button> 38 + </div> 39 + </div> 40 + </div> 41 + </template> 42 + 43 + <style scoped lang="scss"> 44 + .image-previews, 45 + .video-preview { 46 + display: flex; 47 + gap: 0.5rem; 48 + margin-top: 0.5rem; 49 + padding-bottom: 0.5rem; 50 + overflow-x: auto; 51 + } 52 + 53 + .preview-item { 54 + position: relative; 55 + width: 15rem; 56 + height: 15rem; 57 + flex-shrink: 0; 58 + border-radius: var(--radius-md); 59 + border: 1px solid hsla(var(--surface2) / 0.5); 60 + overflow: hidden; 61 + 62 + img, 63 + video { 64 + width: 100%; 65 + height: 100%; 66 + object-fit: cover; 67 + } 68 + 69 + &.video-item { 70 + width: 16rem; 71 + height: unset; 72 + aspect-ratio: var(--aspect-ratio, 16 / 9); 73 + } 74 + 75 + .video-badge { 76 + position: absolute; 77 + top: 0.25rem; 78 + right: calc(0.25rem + 1.25rem + 0.25rem); 79 + padding: 0.25rem 0.7rem; 80 + display: flex; 81 + align-items: center; 82 + justify-content: center; 83 + background: hsla(var(--surface0) / 1); 84 + border-radius: var(--radius-xsm); 85 + 86 + svg { 87 + width: 0.875rem; 88 + height: 0.875rem; 89 + } 90 + } 91 + 92 + .remove-btn { 93 + position: absolute; 94 + top: 0.25rem; 95 + right: 0.25rem; 96 + border: none; 97 + display: flex; 98 + align-items: center; 99 + justify-content: center; 100 + cursor: pointer; 101 + background: hsla(var(--surface0) / 1); 102 + color: hsl(var(--text)); 103 + border-radius: var(--radius-xsm); 104 + min-width: 1.25rem; 105 + height: 1.25rem; 106 + 107 + &:hover { 108 + background: hsla(var(--surface1) / 0.9); 109 + } 110 + 111 + &:disabled { 112 + cursor: not-allowed; 113 + opacity: 0.5; 114 + } 115 + 116 + svg { 117 + width: 0.9rem; 118 + height: 0.9rem; 119 + } 120 + } 121 + } 122 + </style>
+166
src/components/Composer/ComposerToolbar.vue
··· 1 + <script lang="ts" setup> 2 + import { ref } from 'vue' 3 + import { 4 + IconSentimentSatisfiedRounded, 5 + IconLanguage, 6 + IconImageRounded, 7 + } from '@iconify-prerendered/vue-material-symbols' 8 + 9 + import { useComposer } from '@/composables/useComposer' 10 + import BaseButton from '../UI/BaseButton.vue' 11 + 12 + defineProps<{ 13 + composer: ReturnType<typeof useComposer> 14 + submit: () => Promise<void> | void 15 + }>() 16 + 17 + const fileInput = ref<HTMLInputElement | null>(null) 18 + function triggerImageSelect() { 19 + fileInput.value?.click() 20 + } 21 + </script> 22 + 23 + <template> 24 + <div class="toolbar"> 25 + <div class="tools-left"> 26 + <BaseButton 27 + variant="ghost" 28 + icon 29 + pill 30 + @click.stop="triggerImageSelect" 31 + :disabled=" 32 + (composer.images.value.length >= 4 && !composer.hasVideo.value) || composer.loading.value 33 + " 34 + :title="composer.hasVideo ? 'Remove video to add images' : 'Add Image or Video'" 35 + > 36 + <IconImageRounded /> 37 + </BaseButton> 38 + <BaseButton variant="ghost" icon title="Add Emoji" pill :disabled="composer.loading.value"> 39 + <IconSentimentSatisfiedRounded /> 40 + </BaseButton> 41 + <div class="divider"></div> 42 + <BaseButton variant="ghost" class="lang-btn" pill :disabled="composer.loading.value"> 43 + <IconLanguage /> 44 + <span>English</span> 45 + </BaseButton> 46 + </div> 47 + 48 + <div class="tools-right"> 49 + <div 50 + class="char-counter" 51 + :style="{ color: composer.countColour.value }" 52 + :class="{ danger: composer.charsRemaining.value < 20 }" 53 + > 54 + <svg width="24" height="24" viewBox="0 0 24 24" class="circular-chart"> 55 + <circle class="circle-bg" cx="12" cy="12" r="9.9155" /> 56 + <circle 57 + class="circle" 58 + cx="12" 59 + cy="12" 60 + r="9.9155" 61 + :stroke-dasharray="composer.circumference.value" 62 + :stroke-dashoffset="composer.progressDashOffset.value" 63 + style="transform: rotate(-90deg); transform-origin: center" 64 + /> 65 + </svg> 66 + <span class="counter-text">{{ composer.charsRemaining.value }}</span> 67 + </div> 68 + 69 + <BaseButton 70 + variant="primary" 71 + :loading="composer.loading.value" 72 + :disabled=" 73 + (!composer.text.value.trim() && !composer.hasMedia.value) || 74 + composer.charsRemaining.value < 0 || 75 + composer.loading.value 76 + " 77 + pill 78 + @click.stop="submit" 79 + > 80 + {{ composer.loading.value && composer.status.value ? composer.status.value : 'Reply' }} 81 + </BaseButton> 82 + </div> 83 + 84 + <input 85 + type="file" 86 + ref="fileInput" 87 + multiple 88 + accept="image/png, image/jpeg, image/webp, video/mp4, video/webm, video/quicktime" 89 + style="display: none" 90 + @change="composer.handleFileSelect" 91 + /> 92 + </div> 93 + </template> 94 + 95 + <style lang="scss" scoped> 96 + .toolbar { 97 + padding-top: 0.5rem; 98 + display: flex; 99 + justify-content: space-between; 100 + align-items: center; 101 + 102 + .tools-left { 103 + display: flex; 104 + align-items: center; 105 + gap: 0.25rem; 106 + color: hsl(var(--accent)); 107 + 108 + .divider { 109 + width: 1px; 110 + height: 1.25rem; 111 + background-color: hsla(var(--surface2) / 0.5); 112 + margin: 0 0.25rem; 113 + } 114 + 115 + .lang-btn { 116 + font-size: 0.8rem; 117 + font-weight: 600; 118 + gap: 0.35rem; 119 + color: hsl(var(--subtext0)); 120 + } 121 + } 122 + 123 + .tools-right { 124 + display: flex; 125 + align-items: center; 126 + gap: 0.75rem; 127 + } 128 + } 129 + 130 + .char-counter { 131 + position: relative; 132 + display: flex; 133 + align-items: center; 134 + justify-content: center; 135 + width: 1.5rem; 136 + height: 1.5rem; 137 + 138 + .circular-chart { 139 + transform: rotate(-90deg); 140 + width: 100%; 141 + height: 100%; 142 + } 143 + 144 + .circle-bg { 145 + fill: none; 146 + stroke: hsla(var(--surface2) / 0.5); 147 + stroke-width: 2.5; 148 + } 149 + 150 + .circle { 151 + fill: none; 152 + stroke: currentColor; 153 + stroke-width: 2.5; 154 + stroke-linecap: round; 155 + } 156 + 157 + .counter-text { 158 + position: absolute; 159 + font-size: 0.7rem; 160 + font-weight: 700; 161 + color: currentColor; 162 + right: 1.75rem; 163 + white-space: nowrap; 164 + } 165 + } 166 + </style>
+82
src/components/Composer/PostComposer.vue
··· 1 + <script setup lang="ts"> 2 + import { usePostStore } from '@/stores/posts' 3 + import { useComposer } from '@/composables/useComposer' 4 + 5 + import TextArea from '@/components/UI/TextArea.vue' 6 + import ComposerToolbar from './ComposerToolbar.vue' 7 + import ComposerMedia from './ComposerMedia.vue' 8 + import ComposerErrors from './ComposerErrors.vue' 9 + 10 + const emit = defineEmits<{ 11 + (e: 'success'): void 12 + (e: 'close'): void 13 + }>() 14 + 15 + const store = usePostStore() 16 + const composer = useComposer() 17 + 18 + async function handleSubmit() { 19 + const success = await composer.submit(async (text, embeds) => { 20 + await store.createPost({ 21 + text, 22 + embeds, 23 + }) 24 + }) 25 + 26 + if (success) { 27 + emit('success') 28 + emit('close') 29 + } 30 + } 31 + </script> 32 + 33 + <template> 34 + <div class="post-composer"> 35 + <div class="composer-body"> 36 + <TextArea 37 + v-model="composer.text.value" 38 + autoresize 39 + placeholder="skeet yo stuff!" 40 + @keydown="(e: KeyboardEvent) => composer.handleKeyDown(e, handleSubmit)" 41 + /> 42 + <ComposerMedia :composer="composer" /> 43 + <ComposerErrors :composer="composer" /> 44 + </div> 45 + 46 + <div class="composer-footer"> 47 + <ComposerToolbar :composer="composer" :submit="handleSubmit" /> 48 + </div> 49 + </div> 50 + </template> 51 + 52 + <style lang="scss" scoped> 53 + .composer-body { 54 + :deep(.input-group) { 55 + margin-bottom: 0; 56 + margin-left: -0.5rem; 57 + margin-right: -0.5rem; 58 + } 59 + :deep(textarea) { 60 + min-height: 2.75rem; 61 + margin-top: 0.5rem; 62 + height: 2.75rem; 63 + padding: 0.5rem; 64 + background: transparent; 65 + width: calc(100% + 1rem); 66 + border: none !important; 67 + resize: none; 68 + cursor: pointer; 69 + font-size: 1rem; 70 + margin-bottom: 0.5rem; 71 + 72 + &:focus-visible { 73 + /* outline: none; */ 74 + background: hsla(var(--surface1) / 0.2); 75 + } 76 + 77 + &::placeholder { 78 + color: hsl(var(--subtext0)); 79 + } 80 + } 81 + } 82 + </style>
+303
src/components/Composer/ReplyComposer.vue
··· 1 + <script setup lang="ts"> 2 + import { ref, nextTick } from 'vue' 3 + import type { AppBskyFeedDefs } from '@atcute/bluesky' 4 + 5 + import TextArea from '@/components/UI/TextArea.vue' 6 + 7 + import { usePostStore } from '@/stores/posts' 8 + import { useAuthStore } from '@/stores/auth' 9 + import { useComposer } from '@/composables/useComposer' 10 + 11 + import ComposerToolbar from './ComposerToolbar.vue' 12 + import ComposerMedia from './ComposerMedia.vue' 13 + import ComposerErrors from './ComposerErrors.vue' 14 + import { createStrongRef } from '@/utils/atproto' 15 + 16 + const props = defineProps<{ 17 + replyTo: AppBskyFeedDefs.PostView 18 + rootPost: AppBskyFeedDefs.PostView 19 + }>() 20 + 21 + const emit = defineEmits<{ 22 + (e: 'success'): void 23 + }>() 24 + 25 + const store = usePostStore() 26 + const auth = useAuthStore() 27 + const composer = useComposer() 28 + 29 + const isExpanded = ref(false) 30 + 31 + async function handleSubmit() { 32 + const success = await composer.submit(async (text, embeds) => { 33 + await store.createPost({ 34 + text, 35 + embeds, 36 + reply: { 37 + parent: createStrongRef(props.replyTo), 38 + root: createStrongRef(props.rootPost), 39 + }, 40 + }) 41 + }) 42 + 43 + if (success) { 44 + emit('success') 45 + if (!composer.text.value && !composer.hasMedia.value) { 46 + isExpanded.value = false 47 + } 48 + } 49 + } 50 + 51 + function expand() { 52 + if (!auth.isAuthenticated || isExpanded.value) return 53 + 54 + isExpanded.value = true 55 + nextTick(() => { 56 + document.getElementById('reply-input')?.focus() 57 + }) 58 + 59 + document.addEventListener('keydown', (e) => { 60 + if (e.key === 'Escape') { 61 + collapse() 62 + } 63 + }) 64 + } 65 + 66 + function collapse() { 67 + if (!composer.text.value && !composer.hasMedia.value) reset() 68 + } 69 + 70 + function reset() { 71 + isExpanded.value = false 72 + composer.reset() 73 + } 74 + </script> 75 + 76 + <template> 77 + <div 78 + class="reply-composer" 79 + :class="{ 80 + 'is-expanded': isExpanded, 81 + 'is-active': auth.isAuthenticated, 82 + 'has-error': composer.charsRemaining.value < 0, 83 + }" 84 + > 85 + <div class="layout"> 86 + <div class="avatar-col"> 87 + <img 88 + v-if="auth.isAuthenticated && auth.profile?.avatar" 89 + :src="auth.profile.avatar" 90 + alt="Avatar" 91 + loading="lazy" 92 + /> 93 + <div v-else class="avatar-fallback"></div> 94 + </div> 95 + 96 + <div class="content-col"> 97 + <div class="input-area" @click="expand" @focusin="expand"> 98 + <TextArea 99 + id="reply-input" 100 + v-model="composer.text.value" 101 + placeholder="Write your reply..." 102 + autoresize 103 + :rows="1" 104 + :disabled="!auth.isAuthenticated || composer.loading.value" 105 + class="composer-textarea" 106 + @keydown="(e: KeyboardEvent) => composer.handleKeyDown(e, handleSubmit)" 107 + /> 108 + 109 + <ComposerMedia :composer="composer" /> 110 + <ComposerErrors :composer="composer" /> 111 + </div> 112 + 113 + <div class="actions-drawer"> 114 + <div class="actions-inner"> 115 + <ComposerToolbar :composer="composer" :submit="handleSubmit" /> 116 + </div> 117 + </div> 118 + </div> 119 + </div> 120 + </div> 121 + </template> 122 + 123 + <style scoped lang="scss"> 124 + .reply-composer { 125 + padding: 1rem; 126 + border-top: 1px solid hsla(var(--surface2) / 0.5); 127 + border-bottom: 1px solid hsla(var(--surface2) / 0.5); 128 + background-color: hsla(var(--surface1) / 0.05); 129 + padding-bottom: 0; 130 + 131 + &.is-active:hover { 132 + background-color: hsla(var(--surface1) / 0.15); 133 + } 134 + 135 + &.is-expanded { 136 + background-color: hsla(var(--surface1) / 0.1); 137 + :deep(textarea) { 138 + background: hsla(var(--mantle) / 0.75); 139 + } 140 + } 141 + } 142 + 143 + .layout { 144 + display: flex; 145 + gap: 0.75rem; 146 + } 147 + 148 + .avatar-col { 149 + flex-shrink: 0; 150 + width: 2.5rem; 151 + height: 2.5rem; 152 + margin-top: 2px; 153 + 154 + img { 155 + width: 100%; 156 + height: 100%; 157 + border-radius: 50%; 158 + object-fit: cover; 159 + background-color: hsl(var(--surface1)); 160 + } 161 + .avatar-fallback { 162 + width: 100%; 163 + height: 100%; 164 + border-radius: 50%; 165 + background-color: hsl(var(--surface2)); 166 + } 167 + } 168 + 169 + .content-col { 170 + flex: 1; 171 + display: flex; 172 + flex-direction: column; 173 + min-width: 0; 174 + } 175 + 176 + .input-area { 177 + :deep(.input-group) { 178 + margin-bottom: 0; 179 + } 180 + :deep(textarea) { 181 + min-height: 2.75rem; 182 + height: 2.75rem; 183 + padding: 0.5rem; 184 + background: transparent; 185 + border: none !important; 186 + resize: none; 187 + cursor: pointer; 188 + font-size: 1rem; 189 + transition: all 0.2s ease; 190 + 191 + &::placeholder { 192 + color: hsl(var(--subtext0)); 193 + } 194 + } 195 + } 196 + 197 + .is-expanded .input-area { 198 + :deep(textarea) { 199 + min-height: 5rem; 200 + height: auto; 201 + cursor: text; 202 + } 203 + } 204 + 205 + .actions-drawer { 206 + display: grid; 207 + grid-template-rows: 0fr; 208 + } 209 + 210 + .actions-drawer { 211 + grid-template-rows: 1fr; 212 + } 213 + 214 + .actions-inner { 215 + position: relative; 216 + 217 + .toolbar { 218 + padding-top: 0.5rem; 219 + display: flex; 220 + justify-content: space-between; 221 + align-items: center; 222 + opacity: 0; 223 + transform: translateY(-5px); 224 + filter: blur(8px); 225 + margin-top: -1rem; 226 + pointer-events: none; 227 + z-index: -1; 228 + } 229 + 230 + .tools-left { 231 + display: flex; 232 + align-items: center; 233 + gap: 0.25rem; 234 + color: hsl(var(--accent)); 235 + 236 + .divider { 237 + width: 1px; 238 + height: 1.25rem; 239 + background-color: hsla(var(--surface2) / 0.5); 240 + margin: 0 0.25rem; 241 + } 242 + 243 + .lang-btn { 244 + font-size: 0.8rem; 245 + font-weight: 600; 246 + gap: 0.35rem; 247 + color: hsl(var(--subtext0)); 248 + } 249 + } 250 + 251 + .tools-right { 252 + display: flex; 253 + align-items: center; 254 + gap: 0.75rem; 255 + } 256 + } 257 + 258 + .is-expanded { 259 + .toolbar { 260 + opacity: 1; 261 + filter: blur(0); 262 + pointer-events: auto; 263 + margin-top: 0.5rem; 264 + } 265 + } 266 + 267 + .char-counter { 268 + position: relative; 269 + display: flex; 270 + align-items: center; 271 + justify-content: center; 272 + width: 1.5rem; 273 + height: 1.5rem; 274 + 275 + .circular-chart { 276 + transform: rotate(-90deg); 277 + width: 100%; 278 + height: 100%; 279 + } 280 + 281 + .circle-bg { 282 + fill: none; 283 + stroke: hsla(var(--surface2) / 0.5); 284 + stroke-width: 2.5; 285 + } 286 + 287 + .circle { 288 + fill: none; 289 + stroke: currentColor; 290 + stroke-width: 2.5; 291 + stroke-linecap: round; 292 + } 293 + 294 + .counter-text { 295 + position: absolute; 296 + font-size: 0.7rem; 297 + font-weight: 700; 298 + color: currentColor; 299 + right: 1.75rem; 300 + white-space: nowrap; 301 + } 302 + } 303 + </style>
-822
src/components/Feed/ReplyComposer.vue
··· 1 - <script setup lang="ts"> 2 - import { ref, computed, nextTick, onUnmounted } from 'vue' 3 - import { 4 - IconImageRounded, 5 - IconSentimentSatisfiedRounded, 6 - IconLanguage as IconLanguageRounded, 7 - IconCloseRounded, 8 - IconVideocamRounded, 9 - IconErrorRounded, 10 - } from '@iconify-prerendered/vue-material-symbols' 11 - 12 - import { Client, ok, simpleFetchHandler } from '@atcute/client' 13 - import type { Did } from '@atcute/lexicons' 14 - import type { 15 - AppBskyFeedDefs, 16 - AppBskyEmbedImages, 17 - AppBskyEmbedVideo, 18 - AppBskyFeedPost, 19 - } from '@atcute/bluesky' 20 - 21 - import TextArea from '@/components/UI/TextArea.vue' 22 - import BaseButton from '@/components/UI/BaseButton.vue' 23 - 24 - import type { CollectionString } from '@/types/atproto' 25 - import { MAX_POST_IMAGE_SIZE, MAX_POST_VIDEO_SIZE, MAX_POST_TEXT_LENGTH } from '@/utils/constants' 26 - import { formatSize } from '@/utils/formatting' 27 - 28 - import { usePostStore } from '@/stores/posts' 29 - import { useAuthStore } from '@/stores/auth' 30 - 31 - const props = defineProps<{ 32 - replyTo: AppBskyFeedDefs.PostView 33 - rootPost: AppBskyFeedDefs.PostView 34 - }>() 35 - 36 - const emit = defineEmits<{ 37 - (e: 'success'): void 38 - }>() 39 - 40 - const store = usePostStore() 41 - const auth = useAuthStore() 42 - 43 - const text = ref('') 44 - const loading = ref(false) 45 - const status = ref('') 46 - 47 - const errors = ref<string[]>([]) 48 - const isExpanded = ref(false) 49 - 50 - const images = ref<File[]>([]) 51 - const imagePreviews = ref<string[]>([]) 52 - const video = ref<File | null>(null) 53 - const videoPreview = ref<string | null>(null) 54 - 55 - const fileInput = ref<HTMLInputElement | null>(null) 56 - 57 - const charCount = computed(() => text.value.length) 58 - const charsRemaining = computed(() => MAX_POST_TEXT_LENGTH - charCount.value) 59 - const countColour = computed(() => { 60 - const THRESHOLD = 15 61 - if (charsRemaining.value < 0) return 'hsl(var(--red))' 62 - if (charsRemaining.value > THRESHOLD) return 'hsl(var(--subtext0))' 63 - 64 - const percent = (THRESHOLD - charsRemaining.value) / THRESHOLD 65 - return `color-mix(in srgb, hsl(var(--subtext0)) ${100 - percent * 100}%, hsl(var(--red)))` 66 - }) 67 - const circumference = computed(() => 2 * Math.PI * 9.9155) 68 - const progressDashOffset = computed( 69 - () => circumference.value * (1 - Math.min(charCount.value / MAX_POST_TEXT_LENGTH, 1)), 70 - ) 71 - 72 - const hasMedia = computed(() => images.value.length > 0 || video.value !== null) 73 - const hasVideo = computed(() => video.value !== null) 74 - 75 - async function getVideoRpc(lxm: CollectionString, aud: Did = 'did:web:video.bsky.app') { 76 - const _rpc = auth.getRpc() 77 - 78 - const token = ok( 79 - await _rpc.get('com.atproto.server.getServiceAuth', { 80 - params: { 81 - aud: aud, 82 - lxm: lxm, 83 - }, 84 - }), 85 - ).token 86 - 87 - const handler = simpleFetchHandler({ service: 'https://video.bsky.app' }) 88 - const rpc = new Client({ handler }) 89 - 90 - return { rpc, token } 91 - } 92 - 93 - async function canUploadVideo() { 94 - const { rpc, token } = await getVideoRpc('app.bsky.video.getUploadLimits') 95 - 96 - const uploadLimits = ok( 97 - await rpc.get('app.bsky.video.getUploadLimits', { 98 - headers: { 99 - Authorization: `Bearer ${token}`, 100 - }, 101 - }), 102 - ) 103 - 104 - return uploadLimits 105 - } 106 - 107 - async function processImages(): Promise<AppBskyEmbedImages.Main> { 108 - const blobs: AppBskyEmbedImages.Image[] = [] 109 - 110 - for (let i = 0; i < images.value.length; i++) { 111 - const file = images.value[i] 112 - status.value = `Uploading image ${i + 1}/${images.value.length}...` 113 - if (!file) throw new Error('No file selected') 114 - 115 - try { 116 - const rpc = auth.getRpc() 117 - const data = ok( 118 - await rpc.post('com.atproto.repo.uploadBlob', { 119 - input: await file.arrayBuffer(), 120 - headers: { 121 - 'Content-Type': file.type, 122 - }, 123 - }), 124 - ) 125 - 126 - blobs.push({ 127 - alt: '', 128 - image: data.blob, 129 - }) 130 - } catch (e) { 131 - console.error('Blob upload failed', e) 132 - throw new Error(`Failed to upload ${file.name}`) 133 - } 134 - } 135 - 136 - return { 137 - $type: 'app.bsky.embed.images' as const, 138 - images: blobs, 139 - } 140 - } 141 - 142 - async function processVideo(): Promise<AppBskyEmbedVideo.Main> { 143 - if (!video.value) throw new Error('No video selected') 144 - status.value = 'Uploading video...' 145 - 146 - try { 147 - const { rpc: uploadRpc, token: uploadToken } = await getVideoRpc( 148 - 'com.atproto.repo.uploadBlob', 149 - `did:web:${auth.session?.info.server.issuer.replace('https://', '')}`, 150 - ) 151 - 152 - const uploadRes = await uploadRpc.post('app.bsky.video.uploadVideo', { 153 - input: await video.value.arrayBuffer(), 154 - params: { 155 - did: auth.userDid, 156 - name: video.value.name, 157 - }, 158 - headers: { 159 - 'Content-Type': video.value.type, 160 - Authorization: `Bearer ${uploadToken}`, 161 - }, 162 - }) 163 - 164 - if (!('did' in uploadRes.data)) { 165 - throw new Error(`Video upload failed with status ${uploadRes.status}`) 166 - } 167 - 168 - // @ts-expect-error: types are incorrect, there is no jobStatus object. 169 - const jobId = uploadRes.data.jobId as string 170 - 171 - const { rpc: jobRpc, token: jobToken } = await getVideoRpc('app.bsky.video.getJobStatus') 172 - const jobRes = await jobRpc.get('app.bsky.video.getJobStatus', { 173 - params: { 174 - did: auth.profile?.did, 175 - jobId: jobId, 176 - }, 177 - headers: { 178 - Authorization: `Bearer ${jobToken}`, 179 - }, 180 - }) 181 - 182 - if (!jobRes.ok) throw new Error(`Video processing failed with status ${jobRes.status}`) 183 - if (jobRes.data.jobStatus.state === 'JOB_STATE_FAILED') 184 - throw new Error(`Video processing error: ${jobRes.data.jobStatus.message}`) 185 - 186 - const { blob } = jobRes.data.jobStatus 187 - if (!blob) throw new Error('Video processing did not return a blob') 188 - 189 - return { 190 - $type: 'app.bsky.embed.video' as const, 191 - video: blob, 192 - } 193 - } catch (e) { 194 - console.error('Video upload failed', e) 195 - throw new Error(`Failed to upload video`) 196 - } 197 - } 198 - 199 - async function processMedia(): Promise<AppBskyFeedPost.Main['embed'] | undefined> { 200 - if (video.value) { 201 - const embed = await processVideo() 202 - return embed ? (embed as AppBskyFeedPost.Main['embed']) : undefined 203 - } else if (images.value.length > 0) { 204 - const embed = await processImages() 205 - return embed ? (embed as AppBskyFeedPost.Main['embed']) : undefined 206 - } 207 - 208 - return undefined 209 - } 210 - 211 - async function submit() { 212 - if ((!text.value.trim() && !hasMedia.value) || charsRemaining.value < 0) return 213 - 214 - loading.value = true 215 - errors.value = [] 216 - status.value = 'Preparing...' 217 - 218 - try { 219 - let embed: AppBskyFeedPost.Main['embed'] | undefined 220 - if (hasMedia.value) embed = await processMedia() 221 - status.value = 'Sending...' 222 - 223 - await store.reply( 224 - { uri: props.replyTo.uri, cid: props.replyTo.cid }, 225 - { uri: props.rootPost.uri, cid: props.rootPost.cid }, 226 - text.value, 227 - embed, 228 - ) 229 - 230 - reset() 231 - emit('success') 232 - } catch (err) { 233 - console.error('Failed to reply', err) 234 - if (err instanceof Error) errors.value = [err.message] 235 - else errors.value = ['Failed to send reply'] 236 - } finally { 237 - loading.value = false 238 - status.value = '' 239 - } 240 - } 241 - 242 - function expand() { 243 - if (!auth.isAuthenticated || isExpanded.value) return 244 - 245 - isExpanded.value = true 246 - nextTick(() => { 247 - document.getElementById('reply-input')?.focus() 248 - }) 249 - 250 - document.addEventListener('keydown', (e) => { 251 - if (e.key === 'Escape') { 252 - collapse() 253 - } 254 - }) 255 - } 256 - 257 - function collapse() { 258 - if (!text.value && !hasMedia.value) reset() 259 - } 260 - 261 - function reset() { 262 - isExpanded.value = false 263 - text.value = '' 264 - errors.value = [] 265 - status.value = '' 266 - 267 - images.value = [] 268 - imagePreviews.value.forEach((url) => URL.revokeObjectURL(url)) 269 - imagePreviews.value = [] 270 - 271 - if (videoPreview.value) URL.revokeObjectURL(videoPreview.value) 272 - video.value = null 273 - videoPreview.value = null 274 - 275 - if (fileInput.value) fileInput.value.value = '' 276 - } 277 - 278 - function triggerImageSelect() { 279 - fileInput.value?.click() 280 - } 281 - 282 - function validateFileSize(file: File, isVideo: boolean): string | null { 283 - const maxSize = isVideo ? MAX_POST_VIDEO_SIZE : MAX_POST_IMAGE_SIZE 284 - const type = isVideo ? 'Video' : 'Image' 285 - 286 - if (file.size > maxSize) { 287 - return `${type} "${file.name}" is too large (${formatSize(file.size)}). Maximum size is ${formatSize(maxSize)}.` 288 - } 289 - 290 - return null 291 - } 292 - 293 - async function handleFileSelect(event: Event) { 294 - const input = event.target as HTMLInputElement 295 - if (!input.files || input.files.length === 0) return 296 - 297 - errors.value = [] 298 - const currentErrors: string[] = [] 299 - 300 - const newFiles = Array.from(input.files) 301 - const videoFiles = newFiles.filter((file) => file.type.startsWith('video/')) 302 - const imageFiles = newFiles.filter((file) => file.type.startsWith('image/')) 303 - 304 - if (videoFiles.length > 0 && imageFiles.length > 0) 305 - currentErrors.push('You can only upload one video or up to four images, not both.') 306 - if (videoFiles.length > 0 && images.value.length > 0) 307 - currentErrors.push('You already have images attached. Remove them to add a video.') 308 - if (imageFiles.length > 0 && video.value) 309 - currentErrors.push('You already have a video attached. Remove it to add images.') 310 - if (videoFiles.length > 1) currentErrors.push('You can only upload one video at a time.') 311 - if (videoFiles.length === 1 && video.value) 312 - currentErrors.push('You can only upload one video. Remove the current one first.') 313 - 314 - const totalImages = images.value.length + imageFiles.length 315 - if (totalImages > 4) 316 - currentErrors.push( 317 - `You can only upload up to four images. You have ${images.value.length}, trying to add ${imageFiles.length}.`, 318 - ) 319 - 320 - if (currentErrors.length > 0) { 321 - errors.value = currentErrors 322 - input.value = '' 323 - return 324 - } 325 - 326 - if (videoFiles.length === 1) { 327 - const uploadLimits = await canUploadVideo() 328 - if (!uploadLimits.canUpload) { 329 - if (uploadLimits.message) errors.value.push(uploadLimits.message) 330 - 331 - if ( 332 - (uploadLimits.remainingDailyBytes && uploadLimits.remainingDailyBytes <= 0) || 333 - (uploadLimits.remainingDailyVideos && uploadLimits.remainingDailyVideos <= 0) 334 - ) { 335 - errors.value.push("You've hit your daily video upload quota.") 336 - } else if ( 337 - uploadLimits.remainingDailyBytes && 338 - uploadLimits.remainingDailyBytes < video.value!.size 339 - ) { 340 - errors.value.push( 341 - `Video is too large for remaining quota (${formatSize(uploadLimits.remainingDailyBytes)} left).`, 342 - ) 343 - } 344 - 345 - return 346 - } 347 - 348 - const file = videoFiles[0] 349 - if (!file) return 350 - const sizeError = validateFileSize(file, true) 351 - 352 - if (sizeError) { 353 - errors.value = [sizeError] 354 - input.value = '' 355 - return 356 - } 357 - 358 - video.value = file 359 - videoPreview.value = URL.createObjectURL(file) 360 - input.value = '' 361 - return 362 - } 363 - 364 - const sizeErrors: string[] = [] 365 - for (const file of imageFiles) { 366 - const sizeError = validateFileSize(file, false) 367 - if (sizeError) sizeErrors.push(sizeError) 368 - } 369 - 370 - if (sizeErrors.length > 0) { 371 - errors.value = sizeErrors 372 - input.value = '' 373 - return 374 - } 375 - 376 - for (const file of imageFiles) { 377 - images.value.push(file) 378 - imagePreviews.value.push(URL.createObjectURL(file)) 379 - } 380 - 381 - input.value = '' 382 - } 383 - 384 - function removeImage(index: number) { 385 - const image = imagePreviews.value[index] 386 - if (!image) return 387 - URL.revokeObjectURL(image) 388 - imagePreviews.value.splice(index, 1) 389 - images.value.splice(index, 1) 390 - errors.value = [] 391 - } 392 - 393 - function removeVideo() { 394 - if (videoPreview.value) URL.revokeObjectURL(videoPreview.value) 395 - video.value = null 396 - videoPreview.value = null 397 - errors.value = [] 398 - } 399 - 400 - function handleKeydown(e: KeyboardEvent) { 401 - if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { 402 - submit() 403 - } 404 - } 405 - 406 - onUnmounted(() => { 407 - imagePreviews.value.forEach((url) => URL.revokeObjectURL(url)) 408 - if (videoPreview.value) URL.revokeObjectURL(videoPreview.value) 409 - }) 410 - </script> 411 - 412 - <template> 413 - <div 414 - class="reply-composer" 415 - :class="{ 416 - 'is-expanded': isExpanded, 417 - 'is-active': auth.isAuthenticated, 418 - 'has-error': charsRemaining < 0, 419 - }" 420 - > 421 - <div class="layout"> 422 - <div class="avatar-col"> 423 - <img 424 - v-if="auth.isAuthenticated && auth.profile?.avatar" 425 - :src="auth.profile.avatar" 426 - alt="Avatar" 427 - loading="lazy" 428 - /> 429 - <div v-else class="avatar-fallback"></div> 430 - </div> 431 - 432 - <div class="content-col"> 433 - <div class="input-area" @click="expand" @focusin="expand"> 434 - <TextArea 435 - id="reply-input" 436 - v-model="text" 437 - placeholder="Write your reply..." 438 - :rows="1" 439 - :disabled="!auth.isAuthenticated || loading" 440 - class="composer-textarea" 441 - @keydown="handleKeydown" 442 - /> 443 - 444 - <div v-if="videoPreview" class="video-preview"> 445 - <div class="preview-item video-item"> 446 - <video :src="videoPreview" muted preload="metadata" /> 447 - <div class="video-badge"> 448 - <IconVideocamRounded /> 449 - </div> 450 - <button class="remove-btn" @click.stop="removeVideo" :disabled="loading"> 451 - <IconCloseRounded /> 452 - </button> 453 - </div> 454 - </div> 455 - 456 - <div v-else-if="imagePreviews.length > 0" class="image-previews"> 457 - <div v-for="(src, idx) in imagePreviews" :key="idx" class="preview-item"> 458 - <img :src="src" alt="preview" /> 459 - <button class="remove-btn" @click.stop="removeImage(idx)" :disabled="loading"> 460 - <IconCloseRounded /> 461 - </button> 462 - </div> 463 - </div> 464 - </div> 465 - 466 - <div v-if="errors.length > 0" class="error-container"> 467 - <div v-for="(err, idx) in errors" :key="idx" class="error-item"> 468 - <IconErrorRounded /> 469 - <span>{{ err }}</span> 470 - </div> 471 - </div> 472 - 473 - <input 474 - type="file" 475 - ref="fileInput" 476 - multiple 477 - accept="image/png, image/jpeg, image/webp, video/mp4, video/webm, video/quicktime" 478 - style="display: none" 479 - @change="handleFileSelect" 480 - /> 481 - 482 - <div class="actions-drawer"> 483 - <div class="actions-inner"> 484 - <div class="toolbar"> 485 - <div class="tools-left"> 486 - <BaseButton 487 - variant="ghost" 488 - icon 489 - @click.stop="triggerImageSelect" 490 - :disabled="(images.length >= 4 && !hasVideo) || loading" 491 - :title="hasVideo ? 'Remove video to add images' : 'Add Image or Video'" 492 - > 493 - <IconImageRounded /> 494 - </BaseButton> 495 - <BaseButton variant="ghost" icon title="Add Emoji" :disabled="loading"> 496 - <IconSentimentSatisfiedRounded /> 497 - </BaseButton> 498 - <div class="divider"></div> 499 - <BaseButton variant="ghost" class="lang-btn" :disabled="loading"> 500 - <IconLanguageRounded /> 501 - <span>English</span> 502 - </BaseButton> 503 - </div> 504 - 505 - <div class="tools-right"> 506 - <div 507 - class="char-counter" 508 - :style="{ color: countColour }" 509 - :class="{ danger: charsRemaining < 20 }" 510 - > 511 - <svg width="24" height="24" viewBox="0 0 24 24" class="circular-chart"> 512 - <circle class="circle-bg" cx="12" cy="12" r="9.9155" /> 513 - <circle 514 - class="circle" 515 - cx="12" 516 - cy="12" 517 - r="9.9155" 518 - :stroke-dasharray="circumference" 519 - :stroke-dashoffset="progressDashOffset" 520 - style="transform: rotate(-90deg); transform-origin: center" 521 - /> 522 - </svg> 523 - <span class="counter-text">{{ charsRemaining }}</span> 524 - </div> 525 - 526 - <BaseButton 527 - variant="primary" 528 - :loading="loading" 529 - :disabled="(!text.trim() && !hasMedia) || charsRemaining < 0 || loading" 530 - @click.stop="submit" 531 - > 532 - {{ loading && status ? status : 'Reply' }} 533 - </BaseButton> 534 - </div> 535 - </div> 536 - </div> 537 - </div> 538 - </div> 539 - </div> 540 - </div> 541 - </template> 542 - 543 - <style scoped lang="scss"> 544 - .reply-composer { 545 - padding: 1rem; 546 - border-top: 1px solid hsla(var(--surface2) / 0.5); 547 - border-bottom: 1px solid hsla(var(--surface2) / 0.5); 548 - background-color: hsla(var(--surface1) / 0.05); 549 - 550 - &.is-active:hover { 551 - background-color: hsla(var(--surface1) / 0.15); 552 - } 553 - 554 - &.is-expanded { 555 - background-color: hsla(var(--surface1) / 0.1); 556 - :deep(textarea) { 557 - background: hsla(var(--mantle) / 0.75); 558 - } 559 - } 560 - } 561 - 562 - .layout { 563 - display: flex; 564 - gap: 0.75rem; 565 - } 566 - 567 - .avatar-col { 568 - flex-shrink: 0; 569 - width: 2.5rem; 570 - height: 2.5rem; 571 - margin-top: 2px; 572 - 573 - img { 574 - width: 100%; 575 - height: 100%; 576 - border-radius: 50%; 577 - object-fit: cover; 578 - background-color: hsl(var(--surface1)); 579 - } 580 - .avatar-fallback { 581 - width: 100%; 582 - height: 100%; 583 - border-radius: 50%; 584 - background-color: hsl(var(--surface2)); 585 - } 586 - } 587 - 588 - .content-col { 589 - flex: 1; 590 - display: flex; 591 - flex-direction: column; 592 - min-width: 0; 593 - } 594 - 595 - .input-area { 596 - :deep(.input-group) { 597 - margin-bottom: 0; 598 - } 599 - :deep(textarea) { 600 - min-height: 2.75rem; 601 - height: 2.75rem; 602 - padding: 0.5rem; 603 - background: transparent; 604 - border: none !important; 605 - resize: none; 606 - cursor: pointer; 607 - font-size: 1rem; 608 - transition: all 0.2s ease; 609 - 610 - &::placeholder { 611 - color: hsl(var(--subtext0)); 612 - } 613 - } 614 - } 615 - 616 - .is-expanded .input-area { 617 - :deep(textarea) { 618 - min-height: 5rem; 619 - height: auto; 620 - cursor: text; 621 - } 622 - } 623 - 624 - .image-previews, 625 - .video-preview { 626 - display: flex; 627 - gap: 0.5rem; 628 - margin-top: 0.5rem; 629 - padding-bottom: 0.5rem; 630 - overflow-x: auto; 631 - } 632 - 633 - .preview-item { 634 - position: relative; 635 - width: 15rem; 636 - height: 15rem; 637 - flex-shrink: 0; 638 - border-radius: var(--radius-md); 639 - border: 1px solid hsla(var(--surface2) / 0.5); 640 - overflow: hidden; 641 - 642 - img, 643 - video { 644 - width: 100%; 645 - height: 100%; 646 - object-fit: cover; 647 - } 648 - 649 - &.video-item { 650 - width: 16rem; 651 - height: unset; 652 - aspect-ratio: var(--aspect-ratio, 16 / 9); 653 - } 654 - 655 - .video-badge { 656 - position: absolute; 657 - top: 0.25rem; 658 - right: calc(0.25rem + 1.25rem + 0.25rem); 659 - 660 - padding: 0.25rem 0.7rem; 661 - display: flex; 662 - align-items: center; 663 - justify-content: center; 664 - 665 - svg { 666 - width: 0.875rem; 667 - height: 0.875rem; 668 - } 669 - } 670 - 671 - .remove-btn, 672 - .video-badge { 673 - background: hsla(var(--surface0) / 1); 674 - color: hsl(var(--text)); 675 - border-radius: var(--radius-xsm); 676 - min-width: 1.25rem; 677 - height: 1.25rem; 678 - &:hover { 679 - background: hsla(var(--surface1) / 0.9); 680 - } 681 - } 682 - 683 - .remove-btn { 684 - position: absolute; 685 - top: 0.25rem; 686 - right: 0.25rem; 687 - 688 - border: none; 689 - display: flex; 690 - align-items: center; 691 - justify-content: center; 692 - cursor: pointer; 693 - 694 - &:disabled { 695 - cursor: not-allowed; 696 - opacity: 0.5; 697 - } 698 - svg { 699 - width: 0.9rem; 700 - height: 0.9rem; 701 - } 702 - } 703 - } 704 - 705 - .error-container { 706 - margin-top: 0.5rem; 707 - display: flex; 708 - flex-direction: column; 709 - gap: 0.25rem; 710 - 711 - .error-item { 712 - display: flex; 713 - align-items: center; 714 - gap: 0.5rem; 715 - font-size: 0.85rem; 716 - color: hsl(var(--red)); 717 - background-color: hsla(var(--red) / 0.1); 718 - padding: 0.5rem; 719 - border-radius: var(--radius-sm); 720 - 721 - svg { 722 - flex-shrink: 0; 723 - width: 1rem; 724 - height: 1rem; 725 - } 726 - } 727 - } 728 - 729 - .actions-drawer { 730 - display: grid; 731 - grid-template-rows: 0fr; 732 - } 733 - 734 - .is-expanded .actions-drawer { 735 - grid-template-rows: 1fr; 736 - } 737 - 738 - .actions-inner { 739 - position: relative; 740 - 741 - .toolbar { 742 - padding-top: 0.5rem; 743 - display: flex; 744 - justify-content: space-between; 745 - align-items: center; 746 - opacity: 0; 747 - transform: translateY(-5px); 748 - position: absolute; 749 - } 750 - 751 - .tools-left { 752 - display: flex; 753 - align-items: center; 754 - gap: 0.25rem; 755 - color: hsl(var(--accent)); 756 - 757 - .divider { 758 - width: 1px; 759 - height: 1.25rem; 760 - background-color: hsla(var(--surface2) / 0.5); 761 - margin: 0 0.25rem; 762 - } 763 - 764 - .lang-btn { 765 - font-size: 0.8rem; 766 - font-weight: 600; 767 - gap: 0.35rem; 768 - color: hsl(var(--subtext0)); 769 - } 770 - } 771 - 772 - .tools-right { 773 - display: flex; 774 - align-items: center; 775 - gap: 0.75rem; 776 - } 777 - } 778 - 779 - .is-expanded .toolbar { 780 - opacity: 1; 781 - transform: translateY(0); 782 - transition-delay: 0.1s; 783 - position: static; 784 - } 785 - 786 - .char-counter { 787 - position: relative; 788 - display: flex; 789 - align-items: center; 790 - justify-content: center; 791 - width: 1.5rem; 792 - height: 1.5rem; 793 - 794 - .circular-chart { 795 - transform: rotate(-90deg); 796 - width: 100%; 797 - height: 100%; 798 - } 799 - 800 - .circle-bg { 801 - fill: none; 802 - stroke: hsla(var(--surface2) / 0.5); 803 - stroke-width: 2.5; 804 - } 805 - 806 - .circle { 807 - fill: none; 808 - stroke: currentColor; 809 - stroke-width: 2.5; 810 - stroke-linecap: round; 811 - } 812 - 813 - .counter-text { 814 - position: absolute; 815 - font-size: 0.7rem; 816 - font-weight: 700; 817 - color: currentColor; 818 - right: 1.75rem; 819 - white-space: nowrap; 820 - } 821 - } 822 - </style>
+6 -6
src/components/Navigation/TabStack.vue
··· 1 1 <script lang="ts" setup> 2 - import { computed, ref, watch, defineAsyncComponent } from 'vue' 2 + import { computed, ref, watch, defineAsyncComponent, type Component } from 'vue' 3 3 import { useNavigationStore } from '@/stores/navigation' 4 4 import { pages, type StackRootNames } from '@/router' 5 5 import { useEnvironmentStore } from '@/stores/environment' ··· 10 10 11 11 const stack = computed(() => nav.stacks[props.tab]) 12 12 13 - // TODO)) rm the `any`s 14 - const registry: Record<string, any> = pages.reduce( 13 + const registry: Record<string, Component> = pages.reduce( 15 14 (acc, page) => { 16 15 const comp = page.component 17 16 acc[page.name] = 18 17 typeof comp === 'function' 19 18 ? defineAsyncComponent({ 20 - loader: comp as unknown as () => Promise<any>, 19 + loader: comp as unknown as () => Promise<Component>, 21 20 }) 22 21 : comp 22 + 23 + console.log(acc) 23 24 return acc 24 25 }, 25 - {} as Record<string, any>, 26 + {} as Record<string, Component>, 26 27 ) 27 28 28 29 const isAnimating = ref(false) ··· 117 118 ]" 118 119 :data-entry-id="entry.id" 119 120 :data-index="index" 120 - :aria-hidden="index !== visualTopIndex" 121 121 :inert="index !== visualTopIndex" 122 122 > 123 123 <Suspense>
+13 -3
src/components/UI/BaseButton.vue
··· 9 9 disabled?: boolean 10 10 flat?: boolean 11 11 type?: 'button' | 'submit' | 'reset' 12 + pill?: boolean 12 13 }>(), 13 14 { 14 15 variant: 'primary', ··· 30 31 :class="[ 31 32 `variant-${variant}`, 32 33 `size-${size}`, 33 - { 'is-icon': icon, 'is-block': block, 'is-loading': loading, 'is-flat': flat }, 34 + { 35 + 'is-icon': icon, 36 + 'is-block': block, 37 + 'is-loading': loading, 38 + 'is-flat': flat, 39 + 'is-pill': pill, 40 + }, 34 41 ]" 35 42 :disabled="disabled || loading" 36 43 @click.stop="emit('click', $event)" ··· 57 64 58 65 &.is-flat { 59 66 border: none !important; 67 + } 68 + &.is-pill { 69 + border-radius: 10rem !important; 60 70 } 61 71 62 72 &:disabled { ··· 161 171 } 162 172 } 163 173 164 - .variant-ghost { 174 + .th-btn.variant-ghost { 165 175 --bg-colour: transparent; 166 176 --text-colour: var(--subtext0); 167 177 --border-colour: transparent; ··· 169 179 border-color: transparent; 170 180 171 181 &:hover:not(:disabled) { 172 - background-color: hsla(var(--surface2) / 0.25); 182 + background-color: hsla(var(--surface1) / 0.35); 173 183 border-color: hsla(var(--surface2) / 0); 174 184 } 175 185 &:active:not(:disabled) {
+251 -241
src/components/UI/BaseModal.vue
··· 4 4 import { useEnvironmentStore } from '@/stores/environment' 5 5 6 6 defineProps<{ 7 - title?: string 8 - width?: string 7 + title?: string 8 + width?: string 9 + }>() 10 + 11 + const emit = defineEmits<{ 12 + (e: 'close'): void 9 13 }>() 10 14 11 15 const isOpen = defineModel<boolean>('open', { required: true }) ··· 14 18 const isMobile = computed(() => env.isMobile) 15 19 16 20 const modalContainerRef = ref<HTMLElement | null>(null) 17 - const modalContentRef = ref<HTMLElement | null>(null) 18 21 const previousActiveElement = ref<HTMLElement | null>(null) 19 22 20 23 const isDragging = ref(false) ··· 23 26 const backdropOpacity = ref(1) 24 27 25 28 const getFocusableElements = (): HTMLElement[] => { 26 - if (!modalContainerRef.value) return [] 27 - return Array.from( 28 - modalContainerRef.value.querySelectorAll( 29 - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', 30 - ), 31 - ) as HTMLElement[] 29 + if (!modalContainerRef.value) return [] 30 + return Array.from( 31 + modalContainerRef.value.querySelectorAll( 32 + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', 33 + ), 34 + ) as HTMLElement[] 32 35 } 33 36 34 37 const trapFocus = (e: KeyboardEvent) => { 35 - if (!isOpen.value || !modalContainerRef.value) return 38 + if (!isOpen.value || !modalContainerRef.value) return 36 39 37 - const focusableContent = getFocusableElements() 38 - if (focusableContent.length === 0) return 40 + const focusableContent = getFocusableElements() 41 + if (focusableContent.length === 0) return 39 42 40 - const firstElement = focusableContent[0] 41 - const lastElement = focusableContent[focusableContent.length - 1] 43 + const firstElement = focusableContent[0] 44 + const lastElement = focusableContent[focusableContent.length - 1] 42 45 43 - if (e.shiftKey) { 44 - if (document.activeElement === firstElement) { 45 - lastElement.focus() 46 - e.preventDefault() 47 - } 48 - } else { 49 - if (document.activeElement === lastElement) { 50 - firstElement.focus() 51 - e.preventDefault() 52 - } 53 - } 46 + if (e.shiftKey) { 47 + if (document.activeElement === firstElement) { 48 + if (lastElement) lastElement.focus() 49 + e.preventDefault() 50 + } 51 + } else { 52 + if (document.activeElement === lastElement) { 53 + if (firstElement) firstElement.focus() 54 + e.preventDefault() 55 + } 56 + } 54 57 } 55 58 56 59 const handleKeydown = (e: KeyboardEvent) => { 57 - if (e.key === 'Escape' && isOpen.value) { 58 - isOpen.value = false 59 - } 60 - if (e.key === 'Tab' && isOpen.value) { 61 - trapFocus(e) 62 - } 60 + if (e.key === 'Escape' && isOpen.value) { 61 + isOpen.value = false 62 + } 63 + if (e.key === 'Tab' && isOpen.value) { 64 + trapFocus(e) 65 + } 63 66 } 64 67 65 68 // Drag Event Handlers 66 69 const onTouchStart = (e: TouchEvent) => { 67 - if (!isMobile.value) return 68 - // Only allow dragging from the header area or handle 69 - const target = e.target as HTMLElement 70 - if (target.closest('.modal-body') || target.closest('.modal-footer')) return 70 + if (!isMobile.value) return 71 + // Only allow dragging from the header area or handle 72 + const target = e.target as HTMLElement 73 + if (target.closest('.modal-body') || target.closest('.modal-footer')) return 74 + if (!e.touches[0]) return 71 75 72 - isDragging.value = true 73 - startY.value = e.touches[0].clientY 74 - currentY.value = 0 76 + isDragging.value = true 77 + startY.value = e.touches[0].clientY 78 + currentY.value = 0 75 79 } 76 80 77 81 const onTouchMove = (e: TouchEvent) => { 78 - if (!isDragging.value) return 82 + if (!isDragging.value) return 83 + if (!e.touches[0]) return 79 84 80 - const deltaY = e.touches[0].clientY - startY.value 81 - if (deltaY > 0) { 82 - e.preventDefault() // Prevent scrolling while dragging down 83 - currentY.value = deltaY 85 + const deltaY = e.touches[0].clientY - startY.value 86 + if (deltaY > 0) { 87 + e.preventDefault() // Prevent scrolling while dragging down 88 + currentY.value = deltaY 84 89 85 - // Calculate opacity based on drag percentage (assuming ~500px height as threshold) 86 - const percentage = Math.max(0, 1 - deltaY / 400) 87 - backdropOpacity.value = percentage 88 - } 90 + // Calculate opacity based on drag percentage (assuming ~500px height as threshold) 91 + const percentage = Math.max(0, 1 - deltaY / 400) 92 + backdropOpacity.value = percentage 93 + } 89 94 } 90 95 91 96 const onTouchEnd = () => { 92 - if (!isDragging.value) return 93 - isDragging.value = false 97 + if (!isDragging.value) return 98 + isDragging.value = false 94 99 95 - if (currentY.value > 150) { 96 - isOpen.value = false 97 - } else { 98 - // Reset 99 - currentY.value = 0 100 - backdropOpacity.value = 1 101 - } 100 + if (currentY.value > 150) { 101 + isOpen.value = false 102 + } else { 103 + // Reset 104 + currentY.value = 0 105 + backdropOpacity.value = 1 106 + } 102 107 } 103 108 104 109 watch(isOpen, async (val) => { 105 - if (typeof document === 'undefined') return 110 + if (typeof document === 'undefined') return 106 111 107 - if (val) { 108 - // Reset drag state on open 109 - currentY.value = 0 110 - backdropOpacity.value = 1 112 + if (!val) { 113 + emit('close') 114 + } 111 115 112 - previousActiveElement.value = document.activeElement as HTMLElement 113 - document.body.style.overflow = 'hidden' 116 + if (val) { 117 + // Reset drag state on open 118 + currentY.value = 0 119 + backdropOpacity.value = 1 114 120 115 - await nextTick() 121 + previousActiveElement.value = document.activeElement as HTMLElement 122 + document.body.style.overflow = 'hidden' 116 123 117 - if (modalContainerRef.value) { 118 - const focusable = getFocusableElements() 119 - if (focusable.length > 0) { 120 - const firstContentFocus = focusable.find((el) => !el.classList.contains('close-btn')) 121 - ;(firstContentFocus || focusable[0]).focus() 122 - } else { 123 - modalContainerRef.value.focus() 124 - } 125 - } 126 - } else { 127 - document.body.style.overflow = '' 128 - if (previousActiveElement.value) previousActiveElement.value.focus() 129 - } 124 + await nextTick() 125 + 126 + if (modalContainerRef.value) { 127 + const focusable = getFocusableElements() 128 + if (focusable.length > 0) { 129 + const firstContentFocus = focusable.find((el) => !el.classList.contains('close-btn')) 130 + const elementToFocus = firstContentFocus || focusable[0] 131 + if (elementToFocus) elementToFocus.focus() 132 + } else { 133 + modalContainerRef.value.focus() 134 + } 135 + } 136 + } else { 137 + document.body.style.overflow = '' 138 + if (previousActiveElement.value) previousActiveElement.value.focus() 139 + } 130 140 }) 131 141 132 142 onMounted(() => document.addEventListener('keydown', handleKeydown)) 133 143 onUnmounted(() => { 134 - document.removeEventListener('keydown', handleKeydown) 135 - document.body.style.overflow = '' 144 + document.removeEventListener('keydown', handleKeydown) 145 + document.body.style.overflow = '' 136 146 }) 137 147 </script> 138 148 139 149 <template> 140 - <Teleport to="body"> 141 - <Transition name="fade"> 142 - <div 143 - v-if="isOpen" 144 - class="backdrop" 145 - @click="isOpen = false" 146 - aria-hidden="true" 147 - :style="{ opacity: isDragging ? backdropOpacity : undefined }" 148 - ></div> 149 - </Transition> 150 + <Teleport to="body"> 151 + <Transition name="fade"> 152 + <div 153 + v-if="isOpen" 154 + class="backdrop" 155 + @click="isOpen = false" 156 + aria-hidden="true" 157 + :style="{ opacity: isDragging ? backdropOpacity : undefined }" 158 + ></div> 159 + </Transition> 150 160 151 - <Transition :name="isMobile ? 'slide-up' : 'zoom'"> 152 - <div 153 - v-if="isOpen" 154 - ref="modalContainerRef" 155 - class="modal-container" 156 - :class="{ 'is-mobile': isMobile, 'is-desktop': !isMobile }" 157 - role="dialog" 158 - aria-modal="true" 159 - :aria-labelledby="title ? 'modal-title-id' : undefined" 160 - tabindex="-1" 161 - @click="isOpen = false" 162 - > 163 - <div 164 - ref="modalContentRef" 165 - class="modal-content" 166 - :style="{ 167 - maxWidth: width || '500px', 168 - transform: isMobile && currentY > 0 ? `translateY(${currentY}px)` : undefined, 169 - transition: isDragging ? 'none' : undefined, 170 - }" 171 - @click.stop 172 - @touchstart="onTouchStart" 173 - @touchmove="onTouchMove" 174 - @touchend="onTouchEnd" 175 - > 176 - <div v-if="isMobile" class="drag-handle-wrapper"> 177 - <div class="drag-handle" aria-hidden="true"></div> 178 - </div> 161 + <Transition :name="isMobile ? 'slide-up' : 'zoom'"> 162 + <div 163 + v-if="isOpen" 164 + ref="modalContainerRef" 165 + class="modal-container" 166 + :class="{ 'is-mobile': isMobile, 'is-desktop': !isMobile }" 167 + role="dialog" 168 + aria-modal="true" 169 + :aria-labelledby="title ? 'modal-title-id' : undefined" 170 + tabindex="-1" 171 + @click="isOpen = false" 172 + > 173 + <div 174 + ref="modalContentRef" 175 + class="modal-content" 176 + :style="{ 177 + maxWidth: width || '768px', 178 + transform: isMobile && currentY > 0 ? `translateY(${currentY}px)` : undefined, 179 + transition: isDragging ? 'none' : undefined, 180 + }" 181 + @click.stop 182 + @touchstart="onTouchStart" 183 + @touchmove="onTouchMove" 184 + @touchend="onTouchEnd" 185 + > 186 + <div v-if="isMobile" class="drag-handle-wrapper"> 187 + <div class="drag-handle" aria-hidden="true"></div> 188 + </div> 179 189 180 - <div class="modal-header"> 181 - <h2 v-if="title" id="modal-title-id" class="modal-title">{{ title }}</h2> 190 + <div class="modal-header"> 191 + <h2 v-if="title" id="modal-title-id" class="modal-title">{{ title }}</h2> 182 192 183 - <button 184 - class="close-btn" 185 - @click="isOpen = false" 186 - aria-label="Close modal" 187 - type="button" 188 - > 189 - <IconCloseRounded /> 190 - </button> 191 - </div> 193 + <button 194 + class="close-btn" 195 + @click="isOpen = false" 196 + aria-label="Close modal" 197 + type="button" 198 + > 199 + <IconCloseRounded /> 200 + </button> 201 + </div> 192 202 193 - <div class="modal-body"> 194 - <slot /> 195 - </div> 203 + <div class="modal-body"> 204 + <slot /> 205 + </div> 196 206 197 - <div v-if="$slots.footer" class="modal-footer"> 198 - <slot name="footer" /> 199 - </div> 200 - </div> 201 - </div> 202 - </Transition> 203 - </Teleport> 207 + <div v-if="$slots.footer" class="modal-footer"> 208 + <slot name="footer" /> 209 + </div> 210 + </div> 211 + </div> 212 + </Transition> 213 + </Teleport> 204 214 </template> 205 215 206 216 <style scoped lang="scss"> 207 217 .backdrop { 208 - position: fixed; 209 - inset: 0; 210 - background: hsla(var(--crust) / 0.6); 211 - backdrop-filter: blur(4px); 212 - z-index: 9998; 213 - transition: opacity 0.1s linear; 218 + position: fixed; 219 + inset: 0; 220 + background: hsla(var(--crust) / 0.6); 221 + backdrop-filter: blur(4px); 222 + z-index: 9998; 223 + transition: opacity 0.1s linear; 214 224 } 215 225 216 226 .modal-container { 217 - position: fixed; 218 - z-index: 9999; 219 - display: flex; 220 - flex-direction: column; 221 - outline-color: transparent; 227 + position: fixed; 228 + z-index: 9999; 229 + display: flex; 230 + flex-direction: column; 231 + outline-color: transparent; 222 232 } 223 233 224 234 .modal-content { 225 - background: hsl(var(--base)); 226 - display: flex; 227 - flex-direction: column; 228 - max-height: 90vh; 229 - width: 100%; 230 - position: relative; 231 - box-shadow: 232 - 0 10px 25px -5px rgba(0, 0, 0, 0.1), 233 - 0 8px 10px -6px rgba(0, 0, 0, 0.1); 234 - will-change: transform; 235 + background: hsl(var(--base)); 236 + display: flex; 237 + flex-direction: column; 238 + max-height: 90vh; 239 + width: 100%; 240 + position: relative; 241 + box-shadow: 242 + 0 10px 25px -5px rgba(0, 0, 0, 0.1), 243 + 0 8px 10px -6px rgba(0, 0, 0, 0.1); 244 + will-change: transform; 235 245 } 236 246 237 247 .is-desktop { 238 - inset: 0; 239 - align-items: center; 240 - justify-content: center; 241 - padding: 1rem; 248 + inset: 0; 249 + align-items: center; 250 + justify-content: center; 251 + padding: 1rem; 242 252 243 - .modal-header { 244 - padding-top: 1.25rem; 245 - } 253 + .modal-header { 254 + padding-top: 1.25rem; 255 + } 246 256 247 - .modal-content { 248 - border-radius: 1rem; 249 - border: 1px solid hsla(var(--surface2) / 0.2); 250 - } 257 + .modal-content { 258 + border-radius: 1rem; 259 + border: 1px solid hsla(var(--surface2) / 0.2); 260 + } 251 261 } 252 262 253 263 .is-mobile { 254 - bottom: 0; 255 - left: 0; 256 - right: 0; 257 - justify-content: flex-end; 264 + bottom: 0; 265 + left: 0; 266 + right: 0; 267 + justify-content: flex-end; 258 268 259 - .modal-content { 260 - border-top-left-radius: 1.5rem; 261 - border-top-right-radius: 1.5rem; 262 - padding-bottom: env(safe-area-inset-bottom, 20px); 263 - max-height: 85vh; 264 - } 269 + .modal-content { 270 + border-top-left-radius: 1.5rem; 271 + border-top-right-radius: 1.5rem; 272 + padding-bottom: env(safe-area-inset-bottom, 20px); 273 + max-height: 85vh; 274 + } 265 275 } 266 276 267 277 .modal-header { 268 - display: flex; 269 - align-items: center; 270 - justify-content: space-between; 271 - padding: 0.5rem 1.5rem 0.5rem; 272 - flex-shrink: 0; 278 + display: flex; 279 + align-items: center; 280 + justify-content: space-between; 281 + padding: 0.5rem 1.5rem 0.5rem; 282 + flex-shrink: 0; 273 283 274 - .modal-title { 275 - font-size: 1.25rem; 276 - font-weight: 700; 277 - color: hsl(var(--text)); 278 - margin: 0; 279 - } 284 + .modal-title { 285 + font-size: 1.25rem; 286 + font-weight: 700; 287 + color: hsl(var(--text)); 288 + margin: 0; 289 + } 280 290 } 281 291 282 292 .drag-handle-wrapper { 283 - width: 100%; 284 - display: flex; 285 - justify-content: center; 286 - padding-top: 0.75rem; 287 - padding-bottom: 0.25rem; 288 - touch-action: none; 293 + width: 100%; 294 + display: flex; 295 + justify-content: center; 296 + padding-top: 0.75rem; 297 + padding-bottom: 0.25rem; 298 + touch-action: none; 289 299 290 - .drag-handle { 291 - width: 3rem; 292 - height: 0.25rem; 293 - background: hsl(var(--surface2)); 294 - border-radius: 99px; 295 - } 300 + .drag-handle { 301 + width: 3rem; 302 + height: 0.25rem; 303 + background: hsl(var(--surface2)); 304 + border-radius: 99px; 305 + } 296 306 } 297 307 298 308 .close-btn { 299 - background: transparent; 300 - border: none; 301 - font-size: 1.5rem; 302 - color: hsl(var(--subtext0)); 303 - cursor: pointer; 309 + background: transparent; 310 + border: none; 311 + font-size: 1.5rem; 312 + color: hsl(var(--subtext0)); 313 + cursor: pointer; 304 314 305 - width: 2rem; 306 - height: 2rem; 315 + width: 2rem; 316 + height: 2rem; 307 317 308 - display: flex; 309 - align-items: center; 310 - justify-content: center; 318 + display: flex; 319 + align-items: center; 320 + justify-content: center; 311 321 312 - padding: 0; 313 - line-height: 1; 322 + padding: 0; 323 + line-height: 1; 314 324 315 - margin-left: auto; 316 - border-radius: 0.25rem; 325 + margin-left: auto; 326 + border-radius: 0.25rem; 317 327 318 - &:focus-visible { 319 - color: hsl(var(--text)); 320 - background: hsla(var(--surface0) / 0.5); 321 - } 328 + &:focus-visible { 329 + color: hsl(var(--text)); 330 + background: hsla(var(--surface0) / 0.5); 331 + } 322 332 323 - &:hover { 324 - color: hsl(var(--text)); 325 - background: hsla(var(--surface0) / 0.5); 326 - } 333 + &:hover { 334 + color: hsl(var(--text)); 335 + background: hsla(var(--surface0) / 0.5); 336 + } 327 337 } 328 338 329 339 .modal-body { 330 - padding: 0 1.5rem 1.5rem; 331 - overflow-y: auto; 332 - flex: 1; 333 - color: hsl(var(--text)); 340 + padding: 0 1.5rem 1.5rem; 341 + overflow-y: auto; 342 + flex: 1; 343 + color: hsl(var(--text)); 334 344 } 335 345 336 346 .modal-footer { 337 - padding: 1rem 1.5rem; 338 - border-top: 1px solid hsl(var(--surface0)); 339 - display: flex; 340 - gap: 0.5rem; 341 - justify-content: flex-end; 347 + padding: 1rem 1.5rem; 348 + border-top: 1px solid hsl(var(--surface0)); 349 + display: flex; 350 + gap: 0.5rem; 351 + justify-content: flex-end; 342 352 } 343 353 344 354 .fade-enter-active, 345 355 .fade-leave-active { 346 - transition: opacity 0.2s ease; 356 + transition: opacity 0.2s ease; 347 357 } 348 358 .fade-enter-from, 349 359 .fade-leave-to { 350 - opacity: 0; 360 + opacity: 0; 351 361 } 352 362 353 363 .zoom-enter-active, 354 364 .zoom-leave-active { 355 - transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 365 + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); 356 366 } 357 367 .zoom-enter-from, 358 368 .zoom-leave-to { 359 - opacity: 0; 360 - transform: scale(0.95); 369 + opacity: 0; 370 + transform: scale(0.95); 361 371 } 362 372 363 373 .slide-up-enter-active, 364 374 .slide-up-leave-active { 365 - transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); 375 + transition: transform 0.3s cubic-bezier(0.32, 0.72, 0, 1); 366 376 } 367 377 .slide-up-enter-from, 368 378 .slide-up-leave-to { 369 - transform: translateY(100%); 379 + transform: translateY(100%); 370 380 } 371 381 </style>
+37 -6
src/components/UI/TextArea.vue
··· 1 1 <script setup lang="ts"> 2 - import { useId } from 'vue' 2 + import { onMounted, ref, useId } from 'vue' 3 3 4 - defineProps<{ 4 + const props = defineProps<{ 5 5 label?: string 6 6 placeholder?: string 7 7 rows?: number 8 8 error?: string 9 + autoresize?: boolean 9 10 }>() 10 11 11 12 const model = defineModel<string>() 12 13 const id = useId() 14 + 15 + const height = ref() 16 + 17 + const textareaRef = ref<HTMLTextAreaElement | null>(null) 18 + function resize(target: HTMLTextAreaElement) { 19 + target.style.height = 'auto' 20 + target.style.height = `${target.scrollHeight}px` 21 + height.value = target.scrollHeight 22 + } 23 + 24 + function onInput(e: Event) { 25 + if (!props.autoresize) return 26 + resize(e.target as HTMLTextAreaElement) 27 + } 28 + 29 + onMounted(() => { 30 + if (props.autoresize && textareaRef.value) { 31 + resize(textareaRef.value) 32 + } 33 + }) 13 34 </script> 14 35 15 36 <template> 16 37 <div class="input-group"> 17 38 <label v-if="label" :for="id" class="label">{{ label }}</label> 18 39 <textarea 40 + ref="textareaRef" 19 41 :id="id" 20 42 v-model="model" 21 - :rows="rows || 3" 43 + v-bind="props" 44 + rows="1" 45 + :style="{ height: height + 'px' }" 22 46 :placeholder="placeholder" 23 47 class="input textarea" 48 + :oninput="onInput" 24 49 :class="{ 'has-error': error }" 25 50 ></textarea> 26 51 <span v-if="error" class="error-text">{{ error }}</span> ··· 35 60 width: 100%; 36 61 margin-bottom: 1rem; 37 62 } 63 + 38 64 .label { 39 65 font-size: 0.875rem; 40 66 font-weight: 600; 41 67 color: hsl(var(--subtext1)); 42 68 margin-left: 0.25rem; 43 69 } 44 - .input { 70 + 71 + textarea { 45 72 width: 100%; 46 - padding: 0.75rem 1rem; 47 73 border-radius: 0.75rem; 48 74 border: 1px solid transparent; 49 75 background-color: hsla(var(--surface0) / 0.3); ··· 51 77 color: hsl(var(--text)); 52 78 font-size: 1rem; 53 79 font-family: inherit; 54 - resize: vertical; 80 + resize: none; 81 + 82 + box-sizing: border-box; /* usually default, but good to be sure */ 83 + line-height: 1.5; /* or whatever fits your design system */ 84 + min-height: 0; /* overrides some user-agent defaults */ 85 + overflow: hidden; /* hides the scrollbar jumping */ 55 86 56 87 &:focus-visible { 57 88 background-color: hsla(var(--base) / 0.9);
+389
src/composables/useComposer.ts
··· 1 + import { ref, computed, onUnmounted } from 'vue' 2 + import { ok, simpleFetchHandler, Client } from '@atcute/client' 3 + import type { AppBskyFeedPost, AppBskyEmbedImages, AppBskyEmbedVideo } from '@atcute/bluesky' 4 + import type { Did } from '@atcute/lexicons' 5 + import type { CollectionString } from '@/types/atproto' 6 + import { useAuthStore } from '@/stores/auth' 7 + import { MAX_POST_IMAGE_SIZE, MAX_POST_VIDEO_SIZE, MAX_POST_TEXT_LENGTH } from '@/utils/constants' 8 + import { formatSize } from '@/utils/formatting' 9 + 10 + export function useComposer() { 11 + const auth = useAuthStore() 12 + 13 + const text = ref('') 14 + const loading = ref(false) 15 + const status = ref('') 16 + const errors = ref<string[]>([]) 17 + 18 + // media state 19 + const images = ref<File[]>([]) 20 + const imagePreviews = ref<string[]>([]) 21 + const video = ref<File | null>(null) 22 + const videoPreview = ref<string | null>(null) 23 + 24 + const hasMedia = computed(() => images.value.length > 0 || video.value !== null) 25 + const hasVideo = computed(() => video.value !== null) 26 + 27 + // post length stuff 28 + // ==================================================================== 29 + const charCount = computed(() => text.value.length) 30 + const charsRemaining = computed(() => MAX_POST_TEXT_LENGTH - charCount.value) 31 + const countColour = computed(() => { 32 + const WARNING_THRESHOLD = 25 33 + const ACCENT_LIMIT = MAX_POST_TEXT_LENGTH * 0.55 34 + 35 + if (charsRemaining.value < 0) return 'hsl(var(--red))' 36 + 37 + if (charsRemaining.value <= WARNING_THRESHOLD) { 38 + const percent = (WARNING_THRESHOLD - charsRemaining.value) / WARNING_THRESHOLD 39 + return `color-mix(in srgb, hsl(var(--subtext0)) ${100 - percent * 100}%, hsl(var(--red)))` 40 + } 41 + 42 + if (charCount.value > ACCENT_LIMIT) { 43 + return 'hsl(var(--subtext0))' 44 + } 45 + 46 + return 'hsl(var(--accent))' 47 + }) 48 + const circumference = computed(() => 2 * Math.PI * 9.9155) 49 + const progressDashOffset = computed( 50 + () => circumference.value * (1 - Math.min(charCount.value / MAX_POST_TEXT_LENGTH, 1)), 51 + ) 52 + 53 + // media stuff 54 + // ==================================================================== 55 + async function getVideoRpc(lxm: CollectionString, aud: Did = 'did:web:video.bsky.app') { 56 + const _rpc = auth.getRpc() 57 + 58 + const token = ok( 59 + await _rpc.get('com.atproto.server.getServiceAuth', { 60 + params: { 61 + aud: aud, 62 + lxm: lxm, 63 + }, 64 + }), 65 + ).token 66 + 67 + const handler = simpleFetchHandler({ service: 'https://video.bsky.app' }) 68 + const rpc = new Client({ handler }) 69 + 70 + return { rpc, token } 71 + } 72 + 73 + async function canUploadVideo() { 74 + const { rpc, token } = await getVideoRpc('app.bsky.video.getUploadLimits') 75 + 76 + const uploadLimits = ok( 77 + await rpc.get('app.bsky.video.getUploadLimits', { 78 + headers: { 79 + Authorization: `Bearer ${token}`, 80 + }, 81 + }), 82 + ) 83 + 84 + return uploadLimits 85 + } 86 + 87 + async function processImages(): Promise<AppBskyEmbedImages.Main> { 88 + const blobs: AppBskyEmbedImages.Image[] = [] 89 + 90 + for (let i = 0; i < images.value.length; i++) { 91 + const file = images.value[i] 92 + status.value = `Uploading image ${i + 1}/${images.value.length}...` 93 + if (!file) throw new Error('No file selected') 94 + 95 + try { 96 + const rpc = auth.getRpc() 97 + const data = ok( 98 + await rpc.post('com.atproto.repo.uploadBlob', { 99 + input: await file.arrayBuffer(), 100 + headers: { 101 + 'Content-Type': file.type, 102 + }, 103 + }), 104 + ) 105 + 106 + blobs.push({ 107 + alt: '', 108 + image: data.blob, 109 + }) 110 + } catch (e) { 111 + console.error('Blob upload failed', e) 112 + throw new Error(`Failed to upload ${file.name}`) 113 + } 114 + } 115 + 116 + return { 117 + $type: 'app.bsky.embed.images' as const, 118 + images: blobs, 119 + } 120 + } 121 + 122 + async function processVideo(): Promise<AppBskyEmbedVideo.Main> { 123 + if (!video.value) throw new Error('No video selected') 124 + status.value = 'Uploading video...' 125 + 126 + try { 127 + const { rpc: uploadRpc, token: uploadToken } = await getVideoRpc( 128 + 'com.atproto.repo.uploadBlob', 129 + `did:web:${auth.session?.info.server.issuer.replace('https://', '')}`, 130 + ) 131 + 132 + const uploadRes = await uploadRpc.post('app.bsky.video.uploadVideo', { 133 + input: await video.value.arrayBuffer(), 134 + params: { 135 + did: auth.userDid, 136 + name: video.value.name, 137 + }, 138 + headers: { 139 + 'Content-Type': video.value.type, 140 + Authorization: `Bearer ${uploadToken}`, 141 + }, 142 + }) 143 + 144 + if (!('did' in uploadRes.data)) { 145 + throw new Error(`Video upload failed with status ${uploadRes.status}`) 146 + } 147 + 148 + // @ts-expect-error: types are incorrect, there is no jobStatus object. 149 + const jobId = uploadRes.data.jobId as string 150 + 151 + const { rpc: jobRpc, token: jobToken } = await getVideoRpc('app.bsky.video.getJobStatus') 152 + const jobRes = await jobRpc.get('app.bsky.video.getJobStatus', { 153 + params: { 154 + did: auth.profile?.did, 155 + jobId: jobId, 156 + }, 157 + headers: { 158 + Authorization: `Bearer ${jobToken}`, 159 + }, 160 + }) 161 + 162 + if (!jobRes.ok) throw new Error(`Video processing failed with status ${jobRes.status}`) 163 + if (jobRes.data.jobStatus.state === 'JOB_STATE_FAILED') 164 + throw new Error(`Video processing error: ${jobRes.data.jobStatus.message}`) 165 + 166 + const { blob } = jobRes.data.jobStatus 167 + if (!blob) throw new Error('Video processing did not return a blob') 168 + 169 + return { 170 + $type: 'app.bsky.embed.video' as const, 171 + video: blob, 172 + } 173 + } catch (e) { 174 + console.error('Video upload failed', e) 175 + throw new Error(`Failed to upload video`) 176 + } 177 + } 178 + 179 + async function processMedia(): Promise<AppBskyFeedPost.Main['embed'] | undefined> { 180 + if (video.value) { 181 + const embed = await processVideo() 182 + return embed ? (embed as AppBskyFeedPost.Main['embed']) : undefined 183 + } else if (images.value.length > 0) { 184 + const embed = await processImages() 185 + return embed ? (embed as AppBskyFeedPost.Main['embed']) : undefined 186 + } 187 + 188 + return undefined 189 + } 190 + 191 + function validateFileSize(file: File, isVideo: boolean): string | null { 192 + const maxSize = isVideo ? MAX_POST_VIDEO_SIZE : MAX_POST_IMAGE_SIZE 193 + const type = isVideo ? 'Video' : 'Image' 194 + if (file.size > maxSize) { 195 + return `${type} "${file.name}" is too large (${formatSize(file.size)}). Max: ${formatSize(maxSize)}.` 196 + } 197 + return null 198 + } 199 + 200 + async function handleFileSelect(event: Event) { 201 + const input = event.target as HTMLInputElement 202 + if (!input.files || input.files.length === 0) return 203 + 204 + errors.value = [] 205 + const currentErrors: string[] = [] 206 + 207 + const newFiles = Array.from(input.files) 208 + const videoFiles = newFiles.filter((file) => file.type.startsWith('video/')) 209 + const imageFiles = newFiles.filter((file) => file.type.startsWith('image/')) 210 + 211 + if (videoFiles.length > 0 && imageFiles.length > 0) 212 + currentErrors.push('You can only upload one video or up to four images, not both.') 213 + if (videoFiles.length > 0 && images.value.length > 0) 214 + currentErrors.push('You already have images attached. Remove them to add a video.') 215 + if (imageFiles.length > 0 && video.value) 216 + currentErrors.push('You already have a video attached. Remove it to add images.') 217 + if (videoFiles.length > 1) currentErrors.push('You can only upload one video at a time.') 218 + if (videoFiles.length === 1 && video.value) 219 + currentErrors.push('You can only upload one video. Remove the current one first.') 220 + 221 + const totalImages = images.value.length + imageFiles.length 222 + if (totalImages > 4) 223 + currentErrors.push( 224 + `You can only upload up to four images. You have ${images.value.length}, trying to add ${imageFiles.length}.`, 225 + ) 226 + 227 + if (currentErrors.length > 0) { 228 + errors.value = currentErrors 229 + input.value = '' 230 + return 231 + } 232 + 233 + if (videoFiles.length === 1) { 234 + const uploadLimits = await canUploadVideo() 235 + if (!uploadLimits.canUpload) { 236 + if (uploadLimits.message) errors.value.push(uploadLimits.message) 237 + 238 + if ( 239 + (uploadLimits.remainingDailyBytes && uploadLimits.remainingDailyBytes <= 0) || 240 + (uploadLimits.remainingDailyVideos && uploadLimits.remainingDailyVideos <= 0) 241 + ) { 242 + errors.value.push("You've hit your daily video upload quota.") 243 + } else if ( 244 + uploadLimits.remainingDailyBytes && 245 + uploadLimits.remainingDailyBytes < video.value!.size 246 + ) { 247 + errors.value.push( 248 + `Video is too large for remaining quota (${formatSize(uploadLimits.remainingDailyBytes)} left).`, 249 + ) 250 + } 251 + 252 + return 253 + } 254 + 255 + const file = videoFiles[0] 256 + if (!file) return 257 + const sizeError = validateFileSize(file, true) 258 + 259 + if (sizeError) { 260 + errors.value = [sizeError] 261 + input.value = '' 262 + return 263 + } 264 + 265 + video.value = file 266 + videoPreview.value = URL.createObjectURL(file) 267 + input.value = '' 268 + return 269 + } 270 + 271 + const sizeErrors: string[] = [] 272 + for (const file of imageFiles) { 273 + const sizeError = validateFileSize(file, false) 274 + if (sizeError) sizeErrors.push(sizeError) 275 + } 276 + 277 + if (sizeErrors.length > 0) { 278 + errors.value = sizeErrors 279 + input.value = '' 280 + return 281 + } 282 + 283 + for (const file of imageFiles) { 284 + images.value.push(file) 285 + imagePreviews.value.push(URL.createObjectURL(file)) 286 + } 287 + 288 + input.value = '' 289 + } 290 + 291 + function removeImage(index: number) { 292 + const image = imagePreviews.value[index] 293 + if (image) URL.revokeObjectURL(image) 294 + imagePreviews.value.splice(index, 1) 295 + images.value.splice(index, 1) 296 + } 297 + 298 + function removeVideo() { 299 + if (videoPreview.value) URL.revokeObjectURL(videoPreview.value) 300 + video.value = null 301 + videoPreview.value = null 302 + } 303 + 304 + function reset() { 305 + text.value = '' 306 + errors.value = [] 307 + status.value = '' 308 + images.value = [] 309 + imagePreviews.value.forEach((url) => URL.revokeObjectURL(url)) 310 + imagePreviews.value = [] 311 + if (videoPreview.value) URL.revokeObjectURL(videoPreview.value) 312 + video.value = null 313 + videoPreview.value = null 314 + } 315 + 316 + async function submit( 317 + submitAction: ( 318 + text: string, 319 + embeds: AppBskyFeedPost.Main['embed'] | undefined, 320 + ) => Promise<void>, 321 + ): Promise<boolean> { 322 + if ((!text.value.trim() && !hasMedia.value) || charsRemaining.value < 0) { 323 + return false 324 + } 325 + 326 + loading.value = true 327 + status.value = 'Preparing...' 328 + errors.value = [] 329 + 330 + try { 331 + let embed: AppBskyFeedPost.Main['embed'] | undefined 332 + if (hasMedia.value) embed = await processMedia() 333 + 334 + status.value = 'Posting...' 335 + 336 + await submitAction(text.value, embed) 337 + 338 + reset() 339 + return true 340 + } catch (err) { 341 + console.error('Failed to post', err) 342 + errors.value = ['Failed to send post'] 343 + return false 344 + } finally { 345 + loading.value = false 346 + } 347 + } 348 + 349 + function handleKeyDown(e: KeyboardEvent, submitCallback: () => void) { 350 + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { 351 + e.preventDefault() 352 + submitCallback() 353 + } 354 + } 355 + 356 + onUnmounted(() => { 357 + imagePreviews.value.forEach((url) => URL.revokeObjectURL(url)) 358 + if (videoPreview.value) URL.revokeObjectURL(videoPreview.value) 359 + }) 360 + 361 + return { 362 + text, 363 + loading, 364 + status, 365 + errors, 366 + images, 367 + imagePreviews, 368 + video, 369 + videoPreview, 370 + 371 + handleKeyDown, 372 + 373 + hasMedia, 374 + hasVideo, 375 + processMedia, 376 + handleFileSelect, 377 + removeImage, 378 + removeVideo, 379 + reset, 380 + validateFileSize, 381 + submit, 382 + 383 + charCount, 384 + charsRemaining, 385 + circumference, 386 + countColour, 387 + progressDashOffset, 388 + } 389 + }
+11 -11
src/stores/posts.ts
··· 146 146 } 147 147 } 148 148 149 - async function reply( 150 - parent: ComAtprotoRepoStrongRef.Main, 151 - root: ComAtprotoRepoStrongRef.Main, 152 - text: string, 153 - embeds?: AppBskyFeedPost.Main['embed'], 154 - ) { 149 + async function createPost(args: { 150 + text: string 151 + embeds?: AppBskyFeedPost.Main['embed'] 152 + reply?: { 153 + parent: ComAtprotoRepoStrongRef.Main 154 + root: ComAtprotoRepoStrongRef.Main 155 + } 156 + }) { 157 + const { reply, text, embeds } = args 155 158 if (!auth.isAuthenticated || !auth.session) throw new Error('Not authenticated') 156 159 157 160 const rpc = auth.getRpc() ··· 159 162 $type: 'app.bsky.feed.post', 160 163 text, 161 164 createdAt: new Date().toISOString(), 162 - reply: { 163 - root, 164 - parent, 165 - }, 166 165 embed: embeds, 166 + reply: reply, 167 167 } 168 168 169 169 const data = await ok( ··· 184 184 mergePost, 185 185 toggleLike, 186 186 toggleRepost, 187 - reply, 187 + createPost, 188 188 } 189 189 })
+10
src/utils/atproto.ts
··· 1 + import type { AppBskyFeedDefs } from '@atcute/bluesky' 2 + import type { ComAtprotoRepoStrongRef } from '@atcute/atproto' 3 + 4 + export function createStrongRef(post: AppBskyFeedDefs.PostView): ComAtprotoRepoStrongRef.Main { 5 + return { 6 + $type: 'com.atproto.repo.strongRef', 7 + cid: post.cid, 8 + uri: post.uri, 9 + } 10 + }
+2 -2
src/views/Post/PostView.vue
··· 11 11 import PageLayout from '@/components/Navigation/PageLayout.vue' 12 12 import FeedItem from '@/components/Feed/FeedItem.vue' 13 13 import FeedThread from '@/components/Feed/FeedThread.vue' 14 - import ReplyComposer from '@/components/Feed/ReplyComposer.vue' 14 + import PostComposer from '@/components/Composer/ReplyComposer.vue' 15 15 import SkeletonLoader from '@/components/UI/SkeletonLoader.vue' 16 16 import Button from '@/components/UI/BaseButton.vue' 17 17 import { IconRefreshRounded } from '@iconify-prerendered/vue-material-symbols' ··· 180 180 181 181 <div class="main-post-wrapper"> 182 182 <FeedItem :post="thread.post" class="main-post" :rootPost="true" /> 183 - <ReplyComposer 183 + <PostComposer 184 184 v-if="auth.isAuthenticated" 185 185 :replyTo="thread.post" 186 186 :rootPost="ancestors[0] ? ancestors[0].post : thread.post"