wip bsky client for the web & android
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: new modal storeclear!

vi bbb9d548 dbd71971

+933 -813
+17 -112
src/App.vue
··· 2 2 import { ref, onMounted, watch } from 'vue' 3 3 import { App, type URLOpenListenerEvent } from '@capacitor/app' 4 4 5 - import { useNavigationStore } from './stores/navigation' 6 - import { useEnvironmentStore } from './stores/environment' 7 - import { useThemeStore } from './stores/theme' 8 - import { useAuthStore } from './stores/auth' 5 + import { useNavigationStore } from '@/stores/navigation' 6 + import { useEnvironmentStore } from '@/stores/environment' 7 + import { useThemeStore } from '@/stores/theme' 8 + import { useAuthStore } from '@/stores/auth' 9 + import { useModalStore } from '@/stores/modal' 9 10 10 - import SplashScreen from '@/components/Layout/SplashScreen.vue' 11 11 import OAuthCallback from '@/views/Auth/OAuthCallback.vue' 12 12 import OnboardingFlow from '@/views/Onboarding/OnboardingFlow.vue' 13 + 13 14 import AppShell from '@/components/Layout/AppShell.vue' 15 + import SplashScreen from '@/components/Layout/SplashScreen.vue' 16 + import ModalStack from '@/components/UI/ModalStack.vue' 17 + import PronounsModal from '@/components/Modals/PronounsModal.vue' 14 18 15 - import BaseModal from './components/UI/BaseModal.vue' 16 - import TextInput from './components/UI/TextInput.vue' 17 - import BaseButton from './components/UI/BaseButton.vue' 18 19 import KEYS from './utils/keys' 19 - import type { AppBskyActorProfile } from '@atcute/bluesky' 20 - import { ok } from '@atcute/client' 21 20 22 21 type AppPhase = 'loading' | 'callback' | 'intro' | 'shell' 23 22 const currentPhase = ref<AppPhase>('loading') ··· 26 25 const env = useEnvironmentStore() 27 26 const theme = useThemeStore() 28 27 const auth = useAuthStore() 28 + const modals = useModalStore() 29 29 30 30 // init stuff 31 31 // ======================================================== ··· 81 81 const profile = auth.profile 82 82 if (!profile?.pronouns) { 83 83 setTimeout(() => { 84 - showWokeModal.value = !localStorage.getItem(KEYS.STATE.WOKE_DISMISSED) 84 + const dismissed = localStorage.getItem(KEYS.STATE.WOKE_DISMISSED) 85 + if (!dismissed) { 86 + modals.open(PronounsModal) 87 + } 85 88 }, 750) 86 89 } 87 90 } ··· 125 128 } 126 129 }) 127 130 }) 128 - 129 - // woke helpers 130 - // ======================================================== 131 - const pronounsError = ref('') 132 - const pronouns = ref('') 133 - const showWokeModal = ref(false) 134 - const updatingPronouns = ref(false) 135 - const suggestedPronouns = ['she/her', 'they/them', 'he/him', 'any'] 136 - 137 - function handlePronounsChange(value: string) { 138 - pronouns.value = value 139 - } 140 - 141 - async function savePronouns() { 142 - try { 143 - updatingPronouns.value = true 144 - 145 - const rpc = auth.getRpc() 146 - const did = auth.userDid 147 - if (!did) return 148 - 149 - const profile = ok( 150 - await rpc.get('com.atproto.repo.getRecord', { 151 - params: { 152 - collection: 'app.bsky.actor.profile', 153 - repo: did, 154 - rkey: 'self', 155 - }, 156 - }), 157 - ) 158 - 159 - await rpc.post('com.atproto.repo.putRecord', { 160 - input: { 161 - collection: 'app.bsky.actor.profile', 162 - repo: auth.userDid, 163 - rkey: 'self', 164 - record: { 165 - ...(profile.value as AppBskyActorProfile.Main), 166 - pronouns: pronouns.value, 167 - } as AppBskyActorProfile.Main, 168 - }, 169 - }) 170 - 171 - localStorage.removeItem(KEYS.STATE.WOKE_DISMISSED) 172 - showWokeModal.value = false 173 - } catch (err) { 174 - if (err instanceof Error) pronounsError.value = err.message 175 - } finally { 176 - updatingPronouns.value = false 177 - } 178 - } 179 - 180 - function remindLater() { 181 - localStorage.setItem(KEYS.STATE.WOKE_DISMISSED, new Date().toISOString()) 182 - showWokeModal.value = false 183 - } 184 131 </script> 185 132 186 133 <template> 187 134 <div class="app-root"> 135 + <ModalStack /> 136 + 188 137 <Transition name="fade" mode="out-in"> 189 138 <SplashScreen v-if="currentPhase === 'loading'" key="loading" /> 190 139 ··· 205 154 v-else-if="currentPhase === 'shell'" 206 155 key="shell" 207 156 class="shell-wrapper" 208 - :inert="showWokeModal" 157 + :inert="modals.stack.length > 0" 209 158 > 210 159 <AppShell /> 211 160 </div> 212 161 </Transition> 213 - 214 - <BaseModal title="Add your pronouns" :open="showWokeModal" width="600px"> 215 - <div class="woke-modal"> 216 - <p> 217 - Let people know how to refer to you! This will be displayed in Bell and other clients that 218 - support displaying pronouns. 219 - </p> 220 - <TextInput 221 - placeholder="e.g. she/her, they/them, he/him, any" 222 - v-model="pronouns" 223 - class="input-pronouns" 224 - /> 225 - <div class="suggested-pronouns"> 226 - <BaseButton 227 - variant="subtle-alt" 228 - size="sm" 229 - pill 230 - v-for="pronoun in suggestedPronouns" 231 - :key="pronoun" 232 - @click="handlePronounsChange(pronoun)" 233 - > 234 - {{ pronoun }} 235 - </BaseButton> 236 - </div> 237 - <div class="pronouns-error"> 238 - <p v-if="pronounsError">{{ pronounsError }}</p> 239 - </div> 240 - </div> 241 - <template #footer> 242 - <BaseButton variant="subtle-alt" size="md" @click="remindLater">Maybe later</BaseButton> 243 - <BaseButton :loading="updatingPronouns" :disabled="!pronouns" @click="savePronouns" 244 - >Save</BaseButton 245 - > 246 - </template> 247 - </BaseModal> 248 162 </div> 249 163 </template> 250 164 ··· 274 188 .fade-enter-from, 275 189 .fade-leave-to { 276 190 opacity: 0; 277 - } 278 - 279 - .woke-modal .input-pronouns { 280 - margin-top: 0.5rem; 281 - } 282 - .woke-modal .suggested-pronouns { 283 - margin-top: -0.75rem; 284 - display: flex; 285 - gap: 0.25rem; 286 191 } 287 192 </style>
+12 -32
src/components/Composer/AltTextModal.vue
··· 1 1 <script setup lang="ts"> 2 - import { ref, watch } from 'vue' 2 + import { ref } from 'vue' 3 + 4 + import { useModalStore } from '@/stores/modal' 3 5 import BaseModal from '@/components/UI/BaseModal.vue' 4 6 import BaseButton from '@/components/UI/BaseButton.vue' 5 7 import TextArea from '@/components/UI/TextArea.vue' 6 8 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) 9 + const props = defineProps<{ initialText: string; imageSrc: string }>() 19 10 const text = ref(props.initialText) 20 11 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 12 function handleSave() { 34 - emit('save', text.value) 35 - isOpen.value = false 13 + const modals = useModalStore() 14 + modals.close(text.value) 36 15 } 37 16 </script> 38 17 39 18 <template> 40 - <BaseModal v-model:open="isOpen" title="Add Image Description" width="600px"> 19 + <BaseModal title="Add Image Description" @close="$emit('close')"> 41 20 <div class="alt-editor"> 42 21 <div class="image-preview"> 43 22 <img :src="imageSrc" alt="Preview" /> 44 23 </div> 45 24 <div class="input-area"> 46 25 <p class="helper-text"> 47 - Alt text describes images for people with visual impairments. Good alt text is concise and 48 - descriptive. 26 + Alt text describes images for people with various disabilities and helps give context to 27 + everyone! Good alt text is concise, descriptive, and communicates the purpose of the 28 + image. 49 29 </p> 50 30 <TextArea 51 31 v-model="text" ··· 58 38 </div> 59 39 60 40 <template #footer> 61 - <BaseButton variant="ghost" @click="isOpen = false">Cancel</BaseButton> 62 - <BaseButton variant="primary" @click="handleSave">Save</BaseButton> 41 + <BaseButton @click="$emit('close')">Cancel</BaseButton> 42 + <BaseButton @click="handleSave">Save</BaseButton> 63 43 </template> 64 44 </BaseModal> 65 45 </template>
+18 -25
src/components/Composer/ComposerMedia.vue
··· 1 1 <script setup lang="ts"> 2 - import { computed, ref } from 'vue' 3 2 import { 4 3 IconVideocamRounded, 5 4 IconCloseRounded, 6 5 IconCheckRounded, 7 6 } from '@iconify-prerendered/vue-material-symbols' 7 + 8 + import AltTextModal from './AltTextModal.vue' 9 + 8 10 import { useComposer } from '@/composables/useComposer' 9 - import AltTextModal from './AltTextModal.vue' 11 + import { useModalStore } from '@/stores/modal' 12 + 13 + const modals = useModalStore() 10 14 11 15 const props = defineProps<{ 12 16 composer: ReturnType<typeof useComposer> 13 17 }>() 14 18 15 - const showAltModal = ref(false) 16 - const editingIndex = ref<number>(-1) 17 - 18 19 function openAltEditor(index: number) { 19 - editingIndex.value = index 20 - showAltModal.value = true 21 - } 20 + const img = props.composer.images.value[index] 21 + if (!img) return 22 22 23 - function handleSaveAlt(text: string) { 24 - props.composer.updateImageAlt(editingIndex.value, text) 23 + modals 24 + .open(AltTextModal, { 25 + initialText: img.alt, 26 + imageSrc: img.preview, 27 + }) 28 + .then((newAltText) => { 29 + if (typeof newAltText === 'string') { 30 + props.composer.updateImageAlt(index, newAltText) 31 + } 32 + }) 25 33 } 26 - 27 - const image = computed(() => { 28 - const tmp = props.composer.images.value[editingIndex.value] 29 - if (!tmp) return { preview: '', alt: '' } 30 - return tmp 31 - }) 32 34 </script> 33 35 34 36 <template> ··· 72 74 </button> 73 75 </div> 74 76 </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 - /> 84 77 </div> 85 78 </template> 86 79
+14 -5
src/components/Composer/PostComposer.vue
··· 1 1 <script setup lang="ts"> 2 + import { useModalStore } from '@/stores/modal' 2 3 import { usePostStore } from '@/stores/posts' 3 4 import { useComposer } from '@/composables/useComposer' 5 + import { useNavigationStore } from '@/stores/navigation' 4 6 5 7 import TextArea from '@/components/UI/TextArea.vue' 6 8 import ComposerToolbar from './ComposerToolbar.vue' ··· 13 15 }>() 14 16 15 17 const store = usePostStore() 18 + const modals = useModalStore() 16 19 const composer = useComposer() 20 + const navigation = useNavigationStore() 17 21 18 22 async function handleSubmit() { 19 23 const success = await composer.submit(async (text, embeds) => { 20 - await store.createPost({ 21 - text, 22 - embeds, 23 - }) 24 + return ( 25 + await store.createPost({ 26 + text, 27 + embeds, 28 + }) 29 + ).uri 24 30 }) 25 31 26 32 if (success) { 33 + modals.close() 27 34 emit('success') 28 - emit('close') 35 + navigation.push('post-thread', { 36 + props: {}, 37 + }) 29 38 } 30 39 } 31 40 </script>
+10 -8
src/components/Composer/ReplyComposer.vue
··· 30 30 31 31 async function handleSubmit() { 32 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 - }) 33 + return ( 34 + await store.createPost({ 35 + text, 36 + embeds, 37 + reply: { 38 + parent: createStrongRef(props.replyTo), 39 + root: createStrongRef(props.rootPost), 40 + }, 41 + }) 42 + ).uri 41 43 }) 42 44 43 45 if (success) {
+6 -17
src/components/Layout/AppShell.vue
··· 2 2 import { computed, ref, onMounted, onUnmounted } from 'vue' 3 3 import { App } from '@capacitor/app' 4 4 import { useNavigationStore } from '@/stores/navigation' 5 + import { useModalStore } from '@/stores/modal' 5 6 import { stackRoots, type StackRootNames } from '@/router' 6 7 7 8 import TabStack from '@/components/Navigation/TabStack.vue' 8 9 import NavigationBar from '@/components/Navigation/NavigationBar.vue' 9 - import PostComposer from '@/components/Composer/PostComposer.vue' 10 - import BaseModal from '@/components/UI/BaseModal.vue' 10 + 11 + import NewPostModal from '../Modals/NewPostModal.vue' 11 12 12 13 const nav = useNavigationStore() 14 + const modals = useModalStore() 15 + 13 16 const activeTab = computed(() => nav.activeTab) 14 17 const tabs: StackRootNames[] = stackRoots.map((p) => p.name) 15 - 16 - const showPostComposerDialog = ref(false) 17 18 18 19 const handleBackNavigation = () => { 19 20 if (!nav.canGoBack) { ··· 44 45 if (!e.ctrlKey) { 45 46 switch (e.key) { 46 47 case 'c': 47 - showPostComposerDialog.value = true 48 - break 49 - case 'Escape': 50 - showPostComposerDialog.value = false 48 + modals.open(NewPostModal) 51 49 break 52 50 } 53 51 } ··· 84 82 /> 85 83 </div> 86 84 <NavigationBar ref="navBar" /> 87 - 88 - <BaseModal 89 - title="New Post" 90 - :open="showPostComposerDialog" 91 - width="600px" 92 - @close="showPostComposerDialog = false" 93 - > 94 - <PostComposer @close="showPostComposerDialog = false" /> 95 - </BaseModal> 96 85 </div> 97 86 </template> 98 87
+10
src/components/Modals/NewPostModal.vue
··· 1 + <script setup lang="ts"> 2 + import PostComposer from '@/components/Composer/PostComposer.vue' 3 + import BaseModal from '@/components/UI/BaseModal.vue' 4 + </script> 5 + 6 + <template> 7 + <BaseModal title="New Post" width="600px"> 8 + <PostComposer /> 9 + </BaseModal> 10 + </template>
+114
src/components/Modals/PronounsModal.vue
··· 1 + <script setup lang="ts"> 2 + import BaseModal from '@/components/UI/BaseModal.vue' 3 + import BaseButton from '@/components/UI/BaseButton.vue' 4 + import TextInput from '@/components/UI/TextInput.vue' 5 + import { ref } from 'vue' 6 + import KEYS from '@/utils/keys' 7 + import type { AppBskyActorProfile } from '@atcute/bluesky' 8 + import { useAuthStore } from '@/stores/auth' 9 + import { ok } from '@atcute/client' 10 + 11 + const auth = useAuthStore() 12 + 13 + const emit = defineEmits(['close']) 14 + const pronounsError = ref('') 15 + const pronouns = ref('') 16 + const updatingPronouns = ref(false) 17 + const suggestedPronouns = ['she/her', 'they/them', 'he/him', 'any'] 18 + 19 + function handlePronounsChange(value: string) { 20 + pronouns.value = value 21 + } 22 + 23 + async function savePronouns() { 24 + try { 25 + updatingPronouns.value = true 26 + 27 + const rpc = auth.getRpc() 28 + const did = auth.userDid 29 + if (!did) return 30 + 31 + const profile = ok( 32 + await rpc.get('com.atproto.repo.getRecord', { 33 + params: { 34 + collection: 'app.bsky.actor.profile', 35 + repo: did, 36 + rkey: 'self', 37 + }, 38 + }), 39 + ) 40 + 41 + await rpc.post('com.atproto.repo.putRecord', { 42 + input: { 43 + collection: 'app.bsky.actor.profile', 44 + repo: auth.userDid, 45 + rkey: 'self', 46 + record: { 47 + ...(profile.value as AppBskyActorProfile.Main), 48 + pronouns: pronouns.value, 49 + } as AppBskyActorProfile.Main, 50 + }, 51 + }) 52 + 53 + localStorage.removeItem(KEYS.STATE.WOKE_DISMISSED) 54 + emit('close') 55 + } catch (err) { 56 + if (err instanceof Error) pronounsError.value = err.message 57 + } finally { 58 + updatingPronouns.value = false 59 + } 60 + } 61 + 62 + function remindLater() { 63 + localStorage.setItem(KEYS.STATE.WOKE_DISMISSED, new Date().toISOString()) 64 + emit('close') 65 + } 66 + </script> 67 + 68 + <template> 69 + <BaseModal title="Add your pronouns" width="600px" @close="$emit('close')"> 70 + <div class="woke-modal"> 71 + <p> 72 + Let people know how to refer to you! This will be displayed in Bell and other clients that 73 + support displaying pronouns. 74 + </p> 75 + <TextInput 76 + placeholder="e.g. she/her, they/them, he/him, any" 77 + v-model="pronouns" 78 + class="input-pronouns" 79 + /> 80 + <div class="suggested-pronouns"> 81 + <BaseButton 82 + variant="subtle-alt" 83 + size="sm" 84 + pill 85 + v-for="pronoun in suggestedPronouns" 86 + :key="pronoun" 87 + @click="handlePronounsChange(pronoun)" 88 + > 89 + {{ pronoun }} 90 + </BaseButton> 91 + </div> 92 + <div class="pronouns-error"> 93 + <p v-if="pronounsError">{{ pronounsError }}</p> 94 + </div> 95 + </div> 96 + <template #footer> 97 + <BaseButton variant="subtle-alt" size="md" @click="remindLater">Maybe later</BaseButton> 98 + <BaseButton :loading="updatingPronouns" :disabled="!pronouns" @click="savePronouns" 99 + >Save</BaseButton 100 + > 101 + </template> 102 + </BaseModal> 103 + </template> 104 + 105 + <style lang="scss" scoped> 106 + .woke-modal .input-pronouns { 107 + margin-top: 0.5rem; 108 + } 109 + .woke-modal .suggested-pronouns { 110 + margin-top: -0.75rem; 111 + display: flex; 112 + gap: 0.25rem; 113 + } 114 + </style>
+196
src/components/Modals/Settings/AboutModal.vue
··· 1 + <script lang="ts" setup> 2 + import { 3 + IconBugReportRounded, 4 + IconFavoriteRounded, 5 + IconCodeRounded, 6 + } from '@iconify-prerendered/vue-material-symbols' 7 + 8 + import bluebellLogo from '@/assets/icons/bluebell.svg?raw' 9 + 10 + import Modal from '@/components/UI/BaseModal.vue' 11 + import Button from '@/components/UI/BaseButton.vue' 12 + import AppLink from '@/components/Navigation/AppLink.vue' 13 + 14 + const appVersion = __APP_VERSION__ 15 + </script> 16 + 17 + <template> 18 + <Modal title="About" width="480px"> 19 + <div class="about-modal"> 20 + <div class="about-header"> 21 + <div class="about-logo" v-html="bluebellLogo"></div> 22 + <div class="about-title-row"> 23 + <h2 class="app-name">Bluebell</h2> 24 + <span class="version-badge">v{{ appVersion }}</span> 25 + </div> 26 + <p class="about-desc">A beautiful and fast client for the Atmosphere & Bluesky.</p> 27 + </div> 28 + 29 + <div class="about-actions"> 30 + <a 31 + href="https://tangled.sh/vt3e.cat/bluebell" 32 + target="_blank" 33 + rel="noopener" 34 + class="action-card" 35 + > 36 + <IconCodeRounded class="action-icon" /> 37 + <span>Source</span> 38 + </a> 39 + <a 40 + href="https://tangled.sh/vt3e.cat/bluebell/issues" 41 + target="_blank" 42 + rel="noopener" 43 + class="action-card" 44 + > 45 + <IconBugReportRounded class="action-icon" /> 46 + <span>Issues</span> 47 + </a> 48 + </div> 49 + 50 + <div class="about-credits"> 51 + <p> 52 + Created by 53 + <AppLink name="user-profile" :params="{ id: 'vt3e.cat' }" class="credit-link"> 54 + @vt3e.cat 55 + </AppLink> 56 + </p> 57 + <p class="tech-stack"> 58 + Powered by Vue 3, Pinia & atcute. <br /> 59 + <span class="heart"><IconFavoriteRounded /></span> 60 + </p> 61 + </div> 62 + </div> 63 + <template #footer> 64 + <Button variant="primary" @click="$emit('close')">Close</Button> 65 + </template> 66 + </Modal> 67 + </template> 68 + 69 + <style lang="scss" scoped> 70 + .about-modal { 71 + display: flex; 72 + flex-direction: column; 73 + gap: 2rem; 74 + padding: 0.5rem 0; 75 + text-align: center; 76 + 77 + .about-header { 78 + display: flex; 79 + flex-direction: column; 80 + align-items: center; 81 + gap: 1rem; 82 + 83 + .about-logo { 84 + width: 5rem; 85 + height: 5rem; 86 + color: hsl(var(--accent)); 87 + :deep(svg) { 88 + width: 100%; 89 + height: 100%; 90 + } 91 + } 92 + 93 + .about-title-row { 94 + display: flex; 95 + align-items: center; 96 + gap: 0.75rem; 97 + 98 + .app-name { 99 + font-size: 2rem; 100 + font-weight: 800; 101 + color: hsl(var(--text)); 102 + line-height: 1; 103 + } 104 + 105 + .version-badge { 106 + background: hsla(var(--accent) / 0.1); 107 + color: hsl(var(--accent)); 108 + font-size: 0.75rem; 109 + font-weight: 700; 110 + padding: 0.25rem 0.5rem; 111 + border-radius: 99px; 112 + font-family: monospace; 113 + } 114 + } 115 + 116 + .about-desc { 117 + color: hsl(var(--subtext0)); 118 + font-size: 0.95rem; 119 + line-height: 1.5; 120 + max-width: 300px; 121 + } 122 + } 123 + 124 + .about-actions { 125 + display: flex; 126 + justify-content: center; 127 + gap: 0.75rem; 128 + 129 + .action-card { 130 + display: flex; 131 + flex-direction: column; 132 + align-items: center; 133 + justify-content: center; 134 + gap: 0.5rem; 135 + padding: 1rem; 136 + min-width: 8rem; 137 + background: hsla(var(--surface1) / 0.1); 138 + border-radius: var(--radius-md); 139 + text-decoration: none; 140 + color: hsl(var(--text)); 141 + border: 1px solid transparent; 142 + 143 + .action-icon { 144 + font-size: 1.5rem; 145 + color: hsl(var(--accent)); 146 + :deep(svg) { 147 + aspect-ratio: 1 / 1; 148 + } 149 + } 150 + 151 + span { 152 + font-size: 0.8rem; 153 + font-weight: 600; 154 + } 155 + 156 + &:hover { 157 + background: hsla(var(--surface1) / 0.2); 158 + } 159 + &:active { 160 + background: hsla(var(--surface1) / 0.075); 161 + } 162 + } 163 + } 164 + 165 + .about-credits { 166 + display: flex; 167 + flex-direction: column; 168 + gap: 0.5rem; 169 + font-size: 0.9rem; 170 + color: hsl(var(--subtext0)); 171 + 172 + .credit-link { 173 + color: hsl(var(--text)); 174 + font-weight: 600; 175 + text-decoration: none; 176 + &:hover { 177 + text-decoration: underline; 178 + color: hsl(var(--accent)); 179 + } 180 + } 181 + 182 + .tech-stack { 183 + font-size: 0.8rem; 184 + opacity: 0.7; 185 + } 186 + 187 + .heart { 188 + color: hsl(var(--red)); 189 + display: inline-flex; 190 + vertical-align: middle; 191 + margin-top: 0.25rem; 192 + font-size: 1rem; 193 + } 194 + } 195 + } 196 + </style>
+91
src/components/Modals/Settings/AccentModal.vue
··· 1 + <script lang="ts" setup> 2 + import { IconCheckRounded } from '@iconify-prerendered/vue-material-symbols' 3 + 4 + import { useThemeStore, AccentColours } from '@/stores/theme' 5 + import Modal from '@/components/UI/BaseModal.vue' 6 + import Button from '@/components/UI/BaseButton.vue' 7 + 8 + const themeStore = useThemeStore() 9 + </script> 10 + 11 + <template> 12 + <Modal title="Accent Colour" width="640px"> 13 + <div class="accent-modal"> 14 + <button 15 + v-for="colour in AccentColours" 16 + :key="colour" 17 + class="accent-btn" 18 + :class="{ active: themeStore.preferredAccent === colour }" 19 + @click="themeStore.setAccent(colour)" 20 + :aria-label="`Select ${colour} accent`" 21 + > 22 + <div class="accent-preview" :style="{ backgroundColor: `hsl(var(--${colour}))` }"> 23 + <IconCheckRounded class="check-icon" /> 24 + </div> 25 + <span class="accent-name">{{ colour }}</span> 26 + </button> 27 + </div> 28 + <template #footer> 29 + <Button variant="primary" @click="$emit('close')">Done</Button> 30 + </template> 31 + </Modal> 32 + </template> 33 + 34 + <style lang="scss" scoped> 35 + .accent-modal { 36 + display: grid; 37 + grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); 38 + gap: 1rem; 39 + padding-bottom: 1rem; 40 + 41 + .accent-btn { 42 + display: flex; 43 + flex-direction: column; 44 + align-items: center; 45 + gap: 0.5rem; 46 + background: none; 47 + border: none; 48 + cursor: pointer; 49 + padding: 0.5rem; 50 + border-radius: var(--radius-md); 51 + 52 + svg { 53 + opacity: 0; 54 + } 55 + 56 + &:hover { 57 + background-color: hsl(var(--surface0)); 58 + } 59 + 60 + .accent-preview { 61 + width: 3rem; 62 + height: 3rem; 63 + border-radius: 50%; 64 + display: flex; 65 + align-items: center; 66 + justify-content: center; 67 + color: hsl(var(--base)); 68 + 69 + .check-icon { 70 + font-size: 1.75rem; 71 + } 72 + } 73 + 74 + .accent-name { 75 + font-size: 0.75rem; 76 + color: hsl(var(--subtext0)); 77 + text-transform: capitalize; 78 + font-weight: 700; 79 + } 80 + 81 + &.active { 82 + svg { 83 + opacity: 1; 84 + } 85 + .accent-name { 86 + color: hsl(var(--text)); 87 + } 88 + } 89 + } 90 + } 91 + </style>
+215
src/components/Modals/Settings/ThemeModal.vue
··· 1 + <script lang="ts" setup> 2 + import { computed } from 'vue' 3 + import { IconCheckCircleRounded } from '@iconify-prerendered/vue-material-symbols' 4 + 5 + import Modal from '@/components/UI/BaseModal.vue' 6 + import Button from '@/components/UI/BaseButton.vue' 7 + 8 + import { useThemeStore } from '@/stores/theme' 9 + 10 + const availableThemes = computed(() => themeStore.themes) 11 + const themeStore = useThemeStore() 12 + 13 + const selectTheme = (themeId: string, type: 'light' | 'dark') => { 14 + if (type === 'dark') themeStore.setPreferredDark(themeId) 15 + else themeStore.setPreferredLight(themeId) 16 + } 17 + </script> 18 + 19 + <template> 20 + <Modal title="Select Theme" width="640px"> 21 + <div class="theme-modal"> 22 + <div class="theme-section"> 23 + <div class="section-header">Light Mode</div> 24 + <div class="palette-grid"> 25 + <button 26 + v-for="t in availableThemes.filter((x) => x.type === 'light')" 27 + :key="t.id" 28 + class="palette-card" 29 + :class="{ active: themeStore.preferredLight === t.id }" 30 + @click="selectTheme(t.id, 'light')" 31 + :style="{ 32 + '--t-base': `hsl(${t.variables.base})`, 33 + '--t-mantle': `hsl(${t.variables.mantle})`, 34 + '--t-crust': `hsl(${t.variables.crust})`, 35 + '--t-overlay': `hsl(${t.variables.overlay})`, 36 + '--t-text': `hsl(${t.variables.text})`, 37 + '--t-surface': `hsl(${t.variables.surface0})`, 38 + '--t-blue': `hsl(${t.variables.blue})`, 39 + '--t-pink': `hsl(${t.variables.pink})`, 40 + '--t-green': `hsl(${t.variables.green})`, 41 + }" 42 + > 43 + <div class="card-bg"></div> 44 + <div class="card-content"> 45 + <span class="theme-name">{{ t.name }}</span> 46 + <div class="color-row"> 47 + <div class="color-chip" style="background-color: var(--t-blue)"></div> 48 + <div class="color-chip" style="background-color: var(--t-pink)"></div> 49 + <div class="color-chip" style="background-color: var(--t-green)"></div> 50 + <div class="color-chip" style="background-color: var(--t-text)"></div> 51 + </div> 52 + </div> 53 + <div class="active-ring" v-if="themeStore.preferredLight === t.id"> 54 + <IconCheckCircleRounded /> 55 + </div> 56 + </button> 57 + </div> 58 + </div> 59 + 60 + <div class="theme-section"> 61 + <div class="section-header">Dark Mode</div> 62 + <div class="palette-grid"> 63 + <button 64 + v-for="t in availableThemes.filter((x) => x.type === 'dark')" 65 + :key="t.id" 66 + class="palette-card" 67 + :class="{ active: themeStore.preferredDark === t.id }" 68 + @click="selectTheme(t.id, 'dark')" 69 + :style="{ 70 + '--t-base': `hsl(${t.variables.base})`, 71 + '--t-mantle': `hsl(${t.variables.mantle})`, 72 + '--t-crust': `hsl(${t.variables.crust})`, 73 + '--t-text': `hsl(${t.variables.text})`, 74 + '--t-surface': `hsl(${t.variables.surface0})`, 75 + '--t-blue': `hsl(${t.variables.blue})`, 76 + '--t-pink': `hsl(${t.variables.pink})`, 77 + '--t-green': `hsl(${t.variables.green})`, 78 + }" 79 + > 80 + <div class="card-bg"></div> 81 + <div class="card-content"> 82 + <span class="theme-name">{{ t.name }}</span> 83 + <div class="color-row"> 84 + <div class="color-chip" style="background-color: var(--t-blue)"></div> 85 + <div class="color-chip" style="background-color: var(--t-pink)"></div> 86 + <div class="color-chip" style="background-color: var(--t-green)"></div> 87 + <div class="color-chip" style="background-color: var(--t-text)"></div> 88 + </div> 89 + </div> 90 + <div class="active-ring" v-if="themeStore.preferredDark === t.id"> 91 + <IconCheckCircleRounded /> 92 + </div> 93 + </button> 94 + </div> 95 + </div> 96 + </div> 97 + <template #footer> 98 + <Button variant="ghost" @click="$emit('close')">Done</Button> 99 + </template> 100 + </Modal> 101 + </template> 102 + 103 + <style lang="scss" scoped> 104 + .theme-modal { 105 + display: flex; 106 + flex-direction: column; 107 + gap: 2rem; 108 + padding-bottom: 1rem; 109 + 110 + .mini-palette { 111 + display: flex; 112 + gap: 4px; 113 + padding: 4px; 114 + border-radius: 99px; 115 + border: 1px solid hsla(var(--text) / 0.1); 116 + 117 + .dot { 118 + width: 0.75rem; 119 + height: 0.75rem; 120 + border-radius: 50%; 121 + } 122 + } 123 + 124 + .section-header { 125 + font-size: 0.75rem; 126 + text-transform: uppercase; 127 + letter-spacing: 0.05em; 128 + font-weight: 700; 129 + color: hsl(var(--subtext0)); 130 + margin-bottom: 0.75rem; 131 + padding-left: 0.25rem; 132 + } 133 + 134 + .palette-grid { 135 + display: grid; 136 + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); 137 + gap: 1rem; 138 + } 139 + 140 + .palette-card { 141 + position: relative; 142 + display: flex; 143 + flex-direction: column; 144 + border: none; 145 + background: transparent; 146 + padding: 0; 147 + cursor: pointer; 148 + border-radius: var(--radius-md); 149 + overflow: hidden; 150 + text-align: left; 151 + 152 + .card-bg { 153 + position: absolute; 154 + inset: 0; 155 + background-color: var(--t-base); 156 + border: 1px solid hsla(var(--text) / 0.1); 157 + border-radius: var(--radius-md); 158 + } 159 + 160 + .card-content { 161 + position: relative; 162 + z-index: 1; 163 + padding: 1rem; 164 + display: flex; 165 + flex-direction: column; 166 + gap: 0.75rem; 167 + } 168 + 169 + .theme-name { 170 + font-weight: 700; 171 + font-size: 0.9rem; 172 + color: var(--t-text); 173 + } 174 + 175 + .color-row { 176 + display: flex; 177 + gap: 0.5rem; 178 + } 179 + 180 + .color-chip { 181 + width: 1rem; 182 + height: 1rem; 183 + border-radius: 50%; 184 + } 185 + 186 + .active-ring { 187 + position: absolute; 188 + top: 0.5rem; 189 + right: 0.5rem; 190 + z-index: 2; 191 + color: var(--t-blue); 192 + background: var(--t-base); 193 + border-radius: 50%; 194 + width: 1.25rem; 195 + height: 1.25rem; 196 + display: flex; 197 + align-items: center; 198 + justify-content: center; 199 + box-shadow: 0 2px 4px hsla(var(--crust) / 0.1); 200 + } 201 + 202 + &:hover .card-bg { 203 + background-color: var(--t-mantle); 204 + } 205 + &:active .card-bg { 206 + background-color: var(--t-crust); 207 + } 208 + 209 + &.active .card-bg { 210 + border-color: var(--t-blue); 211 + box-shadow: 0 0 0 2px var(--t-blue); 212 + } 213 + } 214 + } 215 + </style>
+26 -26
src/components/Navigation/AppBar.vue
··· 133 133 <style scoped lang="scss"> 134 134 .progressive-blur { 135 135 --blur-strength: 16px; 136 - height: calc(var(--inset-top) + 3.5rem); 136 + height: var(--inset-top, 3.5rem); 137 137 min-height: 3.5rem; 138 138 139 139 position: absolute; ··· 148 148 bottom: 0; 149 149 top: 0; 150 150 } 151 - } 152 151 153 - .progressive-blur-top { 154 - top: 0; 155 - bottom: auto; 156 - z-index: 5; 157 - } 152 + &.progressive-blur-top { 153 + top: 0; 154 + bottom: auto; 155 + z-index: 5; 156 + } 158 157 159 - .progressive-blur-bottom { 160 - bottom: 0; 161 - top: auto; 162 - height: calc(var(--inset-bottom, var(--inset-top)) + 3.5rem); 163 - min-height: 4rem; 164 - z-index: 5; 158 + &.progressive-blur-bottom { 159 + bottom: 0; 160 + top: auto; 161 + height: calc(var(--inset-bottom, var(--inset-top, 3.5rem))); 162 + min-height: 4rem; 163 + z-index: 5; 164 + } 165 165 } 166 166 167 167 .overlay { ··· 170 170 width: 100%; 171 171 pointer-events: none; 172 172 z-index: 99; 173 - } 174 173 175 - .overlay-top { 176 - top: 0; 177 - height: calc(var(--inset-top) + 3.5rem); 178 - min-height: 4.5rem; 179 - background: linear-gradient(to bottom, hsla(var(--base) / 0.8) 0%, hsla(var(--base) / 0) 100%); 180 - } 174 + &.overlay-top { 175 + top: 0; 176 + height: var(--inset-top, 3.5rem); 177 + min-height: 4.5rem; 178 + background: linear-gradient(to bottom, hsla(var(--base) / 1) 0%, hsla(var(--base) / 0) 100%); 179 + } 181 180 182 - .overlay-bottom { 183 - bottom: 0; 184 - top: auto; 185 - height: var(--inset-bottom); 186 - min-height: 4.5rem; 187 - background: linear-gradient(to top, hsla(var(--base) / 0.8) 0%, hsla(var(--base) / 0) 90%); 181 + &.overlay-bottom { 182 + bottom: 0; 183 + top: auto; 184 + height: calc(var(--inset-bottom, var(--inset-top, 3.5rem))); 185 + min-height: 4.5rem; 186 + background: linear-gradient(to top, hsla(var(--base) / 0.8) 0%, hsla(var(--base) / 0) 90%); 187 + } 188 188 } 189 189 190 190 .topbar {
+92 -109
src/components/UI/BaseModal.vue
··· 1 1 <script setup lang="ts"> 2 - import { computed, onMounted, onUnmounted, watch, ref, nextTick } from 'vue' 2 + import { computed, onMounted, onUnmounted, ref, nextTick } from 'vue' 3 3 import { IconCloseRounded } from '@iconify-prerendered/vue-material-symbols' 4 4 import { useEnvironmentStore } from '@/stores/environment' 5 5 6 6 defineProps<{ 7 7 title?: string 8 8 width?: string 9 - id?: string 9 + zIndex?: number 10 10 }>() 11 11 12 12 const emit = defineEmits<{ 13 13 (e: 'close'): void 14 14 }>() 15 15 16 - const isOpen = defineModel<boolean>('open', { required: true }) 17 - 18 16 const env = useEnvironmentStore() 19 17 const isMobile = computed(() => env.isMobile) 20 18 21 19 const modalContainerRef = ref<HTMLElement | null>(null) 22 - const previousActiveElement = ref<HTMLElement | null>(null) 23 20 24 21 const isDragging = ref(false) 25 22 const startY = ref(0) ··· 36 33 } 37 34 38 35 const trapFocus = (e: KeyboardEvent) => { 39 - if (!isOpen.value || !modalContainerRef.value) return 40 - 36 + if (!modalContainerRef.value) return 41 37 const focusableContent = getFocusableElements() 42 38 if (focusableContent.length === 0) return 43 39 ··· 46 42 47 43 if (e.shiftKey) { 48 44 if (document.activeElement === firstElement) { 49 - if (lastElement) lastElement.focus() 45 + lastElement?.focus() 50 46 e.preventDefault() 51 47 } 52 48 } else { 53 49 if (document.activeElement === lastElement) { 54 - if (firstElement) firstElement.focus() 50 + firstElement?.focus() 55 51 e.preventDefault() 56 52 } 57 53 } 58 54 } 59 55 60 56 const handleKeydown = (e: KeyboardEvent) => { 61 - if (e.key === 'Escape' && isOpen.value) { 62 - isOpen.value = false 57 + if (e.key === 'Escape') { 58 + emit('close') 63 59 } 64 - if (e.key === 'Tab' && isOpen.value) { 60 + if (e.key === 'Tab') { 65 61 trapFocus(e) 66 62 } 67 63 } ··· 99 95 isDragging.value = false 100 96 101 97 if (currentY.value > 150) { 102 - isOpen.value = false 98 + emit('close') 103 99 } else { 104 100 // Reset 105 101 currentY.value = 0 ··· 107 103 } 108 104 } 109 105 110 - watch(isOpen, async (val) => { 111 - if (typeof document === 'undefined') return 106 + onMounted(async () => { 107 + document.addEventListener('keydown', handleKeydown) 108 + await nextTick() 112 109 113 - if (!val) { 114 - emit('close') 115 - } 116 - 117 - if (val) { 118 - // Reset drag state on open 119 - currentY.value = 0 120 - backdropOpacity.value = 1 121 - 122 - previousActiveElement.value = document.activeElement as HTMLElement 123 - document.body.style.overflow = 'hidden' 124 - 125 - await nextTick() 126 - 127 - if (modalContainerRef.value) { 128 - const focusable = getFocusableElements() 129 - if (focusable.length > 0) { 130 - const firstContentFocus = focusable.find((el) => !el.classList.contains('close-btn')) 131 - const elementToFocus = firstContentFocus || focusable[0] 132 - if (elementToFocus) elementToFocus.focus() 133 - } else { 134 - modalContainerRef.value.focus() 135 - } 110 + if (modalContainerRef.value) { 111 + const focusable = getFocusableElements() 112 + if (focusable.length > 0) { 113 + const firstContentFocus = focusable.find((el) => !el.classList.contains('close-btn')) 114 + const elementToFocus = firstContentFocus || focusable[0] 115 + elementToFocus?.focus() 116 + } else { 117 + modalContainerRef.value.focus() 136 118 } 137 - } else { 138 - document.body.style.overflow = '' 139 - if (previousActiveElement.value) previousActiveElement.value.focus() 140 119 } 141 120 }) 142 121 143 - onMounted(() => document.addEventListener('keydown', handleKeydown)) 144 122 onUnmounted(() => { 145 123 document.removeEventListener('keydown', handleKeydown) 146 - document.body.style.overflow = '' 147 124 }) 148 125 </script> 149 126 150 127 <template> 151 - <Teleport to="body"> 152 - <Transition name="fade"> 153 - <div 154 - v-if="isOpen" 155 - class="backdrop" 156 - @click="isOpen = false" 157 - aria-hidden="true" 158 - :style="{ opacity: isDragging ? backdropOpacity : undefined }" 159 - ></div> 160 - </Transition> 128 + <div 129 + class="modal-wrapper" 130 + :style="{ zIndex: zIndex || 9999 }" 131 + role="dialog" 132 + aria-modal="true" 133 + :aria-labelledby="title ? 'modal-title-id' : undefined" 134 + > 135 + <div 136 + class="backdrop" 137 + @click="emit('close')" 138 + aria-hidden="true" 139 + :style="{ 140 + opacity: isDragging ? backdropOpacity : undefined, 141 + }" 142 + :class="{ 'is-dragging': isDragging }" 143 + ></div> 161 144 162 - <Transition :name="isMobile ? 'slide-up' : 'zoom'"> 145 + <div 146 + ref="modalContainerRef" 147 + class="modal-container" 148 + :class="{ 'is-mobile': isMobile, 'is-desktop': !isMobile }" 149 + role="dialog" 150 + aria-modal="true" 151 + :aria-labelledby="title ? 'modal-title-id' : undefined" 152 + tabindex="-1" 153 + :style="{ zIndex: (zIndex || 9999) + 1 }" 154 + @click.self="emit('close')" 155 + > 163 156 <div 164 - :id="id" 165 - v-if="isOpen" 166 - ref="modalContainerRef" 167 - class="modal-container" 168 - :class="{ 'is-mobile': isMobile, 'is-desktop': !isMobile }" 169 - role="dialog" 170 - aria-modal="true" 171 - :aria-labelledby="title ? 'modal-title-id' : undefined" 172 - tabindex="-1" 173 - @click="isOpen = false" 157 + class="modal-content" 158 + :style="{ 159 + maxWidth: width || '768px', 160 + transform: isMobile && currentY > 0 ? `translateY(${currentY}px)` : undefined, 161 + transition: isDragging ? 'none' : undefined, 162 + }" 163 + @click.stop 164 + @touchstart="onTouchStart" 165 + @touchmove="onTouchMove" 166 + @touchend="onTouchEnd" 174 167 > 175 - <div 176 - ref="modalContentRef" 177 - class="modal-content" 178 - :style="{ 179 - maxWidth: width || '768px', 180 - transform: isMobile && currentY > 0 ? `translateY(${currentY}px)` : undefined, 181 - transition: isDragging ? 'none' : undefined, 182 - }" 183 - @click.stop 184 - @touchstart="onTouchStart" 185 - @touchmove="onTouchMove" 186 - @touchend="onTouchEnd" 187 - > 188 - <div v-if="isMobile" class="drag-handle-wrapper"> 189 - <div class="drag-handle" aria-hidden="true"></div> 190 - </div> 168 + <div v-if="isMobile" class="drag-handle-wrapper"> 169 + <div class="drag-handle" aria-hidden="true"></div> 170 + </div> 191 171 192 - <div class="modal-header"> 193 - <h2 v-if="title" id="modal-title-id" class="modal-title">{{ title }}</h2> 172 + <div class="modal-header"> 173 + <h2 v-if="title" id="modal-title-id" class="modal-title">{{ title }}</h2> 194 174 195 - <button 196 - class="close-btn" 197 - @click="isOpen = false" 198 - aria-label="Close modal" 199 - type="button" 200 - > 201 - <IconCloseRounded /> 202 - </button> 203 - </div> 175 + <button class="close-btn" @click="emit('close')" aria-label="Close modal" type="button"> 176 + <IconCloseRounded /> 177 + </button> 178 + </div> 204 179 205 - <div class="modal-body"> 206 - <slot /> 207 - </div> 180 + <div class="modal-body"> 181 + <slot /> 182 + </div> 208 183 209 - <div v-if="$slots.footer" class="modal-footer"> 210 - <slot name="footer" /> 211 - </div> 184 + <div v-if="$slots.footer" class="modal-footer"> 185 + <slot name="footer" /> 212 186 </div> 213 187 </div> 214 - </Transition> 215 - </Teleport> 188 + </div> 189 + </div> 216 190 </template> 217 191 218 192 <style scoped lang="scss"> 219 - .backdrop { 193 + .modal-wrapper { 220 194 position: fixed; 221 195 inset: 0; 196 + display: flex; 197 + flex-direction: column; 198 + pointer-events: none; 199 + } 200 + 201 + .backdrop { 202 + position: absolute; 203 + inset: 0; 222 204 background: hsla(var(--crust) / 0.6); 223 205 backdrop-filter: blur(4px); 224 - z-index: 9998; 225 - transition: opacity 0.1s linear; 206 + pointer-events: auto; 207 + &.is-dragging { 208 + transition: none; 209 + } 226 210 } 227 211 228 212 .modal-container { 229 - position: fixed; 230 - z-index: 9999; 213 + position: relative; 214 + inset: 0; 231 215 display: flex; 232 216 flex-direction: column; 233 217 outline-color: transparent; 218 + pointer-events: none; 234 219 } 235 220 236 221 .modal-content { 222 + pointer-events: auto; 237 223 background: hsl(var(--base)); 238 224 display: flex; 239 225 flex-direction: column; ··· 251 237 align-items: center; 252 238 justify-content: center; 253 239 padding: 1rem; 240 + pointer-events: auto; 254 241 255 242 .modal-header { 256 243 padding-top: 1.25rem; ··· 327 314 margin-left: auto; 328 315 border-radius: 0.25rem; 329 316 330 - &:focus-visible { 331 - color: hsl(var(--text)); 332 - background: hsla(var(--surface0) / 0.5); 333 - } 334 - 317 + &:focus-visible, 335 318 &:hover { 336 319 color: hsl(var(--text)); 337 320 background: hsla(var(--surface0) / 0.5);
+39
src/components/UI/ModalStack.vue
··· 1 + <script setup lang="ts"> 2 + import { useModalStore } from '@/stores/modal' 3 + import { storeToRefs } from 'pinia' 4 + 5 + const modalStore = useModalStore() 6 + const { stack } = storeToRefs(modalStore) 7 + </script> 8 + 9 + <template> 10 + <Teleport to="body"> 11 + <TransitionGroup name="modal" tag="div" class="modal-stack-container"> 12 + <component 13 + v-for="(modal, index) in stack" 14 + :key="modal.id" 15 + :is="modal.component" 16 + v-bind="modal.props" 17 + :z-index="9999 + index" 18 + @close="modalStore.close()" 19 + /> 20 + </TransitionGroup> 21 + </Teleport> 22 + </template> 23 + 24 + <style lang="scss"> 25 + .modal-stack-container { 26 + position: relative; 27 + z-index: 9999; 28 + } 29 + 30 + .modal-enter-from, 31 + .modal-leave-to { 32 + opacity: 0; 33 + 34 + .modal-content { 35 + transform: scale(0.95) translateY(64px); 36 + opacity: 0; 37 + } 38 + } 39 + </style>
+7 -8
src/composables/useComposer.ts
··· 1 1 import { ref, computed, onUnmounted } from 'vue' 2 2 import { ok, simpleFetchHandler, Client } from '@atcute/client' 3 3 import type { AppBskyFeedPost, AppBskyEmbedImages, AppBskyEmbedVideo } from '@atcute/bluesky' 4 - import type { Did } from '@atcute/lexicons' 4 + import type { Did, ResourceUri } from '@atcute/lexicons' 5 5 import type { CollectionString } from '@/types/atproto' 6 6 import { useAuthStore } from '@/stores/auth' 7 7 import { MAX_POST_IMAGE_SIZE, MAX_POST_VIDEO_SIZE, MAX_POST_TEXT_LENGTH } from '@/utils/constants' ··· 333 333 submitAction: ( 334 334 text: string, 335 335 embeds: AppBskyFeedPost.Main['embed'] | undefined, 336 - ) => Promise<void>, 337 - ): Promise<boolean> { 336 + ) => Promise<ResourceUri>, 337 + ): Promise<ResourceUri | null> { 338 338 if ((!text.value.trim() && !hasMedia.value) || charsRemaining.value < 0) { 339 - return false 339 + return null 340 340 } 341 341 342 342 loading.value = true ··· 348 348 if (hasMedia.value) embed = await processMedia() 349 349 350 350 status.value = 'Posting...' 351 - 352 - await submitAction(text.value, embed) 351 + const res = await submitAction(text.value, embed) 353 352 354 353 reset() 355 - return true 354 + return res 356 355 } catch (err) { 357 356 console.error('Failed to post', err) 358 357 errors.value = ['Failed to send post'] 359 - return false 358 + return null 360 359 } finally { 361 360 loading.value = false 362 361 }
+54
src/stores/modal.ts
··· 1 + import { defineStore } from 'pinia' 2 + import { ref, shallowRef, markRaw, type Component } from 'vue' 3 + 4 + export interface ModalItem { 5 + id: string 6 + component: Component 7 + props?: Record<string, unknown> 8 + resolve?: (value: unknown) => void 9 + } 10 + 11 + export const useModalStore = defineStore('modal', () => { 12 + const stack = ref<ModalItem[]>([]) 13 + 14 + const uId = () => Math.random().toString(36).substring(2, 9) 15 + 16 + function open<T = Record<string, unknown>>( 17 + component: Component, 18 + props?: Record<string, unknown>, 19 + ): Promise<T | undefined> { 20 + return new Promise((resolve) => { 21 + stack.value.push({ 22 + id: uId(), 23 + component: markRaw(component), 24 + props, 25 + resolve: resolve as (value: unknown) => void, 26 + }) 27 + 28 + document.body.style.overflow = 'hidden' 29 + }) 30 + } 31 + 32 + function close(result?: unknown) { 33 + const item = stack.value.pop() 34 + if (item && item.resolve) { 35 + item.resolve(result) 36 + } 37 + 38 + if (stack.value.length === 0) { 39 + document.body.style.overflow = '' 40 + } 41 + } 42 + 43 + function closeAll() { 44 + stack.value = [] 45 + document.body.style.overflow = '' 46 + } 47 + 48 + return { 49 + stack, 50 + open, 51 + close, 52 + closeAll, 53 + } 54 + })
+12 -471
src/views/SettingsPage.vue
··· 1 1 <script lang="ts" setup> 2 - import { ref, computed } from 'vue' 2 + import { computed } from 'vue' 3 3 4 - import { useThemeStore, AccentColours } from '@/stores/theme' 4 + import { useThemeStore } from '@/stores/theme' 5 5 import { useNavigationStore } from '@/stores/navigation' 6 6 import { useAuthStore } from '@/stores/auth' 7 7 ··· 9 9 import ListGroup from '@/components/UI/ListGroup.vue' 10 10 import ListItem from '@/components/UI/ListItem.vue' 11 11 import ToggleSwitch from '@/components/UI/ToggleSwitch.vue' 12 - import Modal from '@/components/UI/BaseModal.vue' 13 - import Button from '@/components/UI/BaseButton.vue' 14 - import AppLink from '@/components/Navigation/AppLink.vue' 12 + 13 + import { useModalStore } from '@/stores/modal' 14 + const modals = useModalStore() 15 + 16 + import AboutModal from '@/components/Modals/Settings/AboutModal.vue' 17 + import ThemeModal from '@/components/Modals/Settings/ThemeModal.vue' 18 + import AccentModal from '@/components/Modals/Settings/AccentModal.vue' 15 19 16 20 import { 17 21 IconPaletteOutline, 18 22 IconInfoOutlineRounded, 19 - IconCheckCircleRounded, 20 23 IconMoonStarsRounded, 21 24 IconChevronRightRounded, 22 25 IconOpenInBrowserRounded, 23 - IconCheckRounded, 24 26 IconLoginRounded, 25 27 IconLogoutRounded, 26 - IconCodeRounded, 27 - IconBugReportRounded, 28 - IconFavoriteRounded, 29 28 } from '@iconify-prerendered/vue-material-symbols' 30 29 31 30 import tangledLogo from '@/assets/icons/tangled.svg?raw' ··· 37 36 const nav = useNavigationStore() 38 37 const auth = useAuthStore() 39 38 40 - const showThemeModal = ref(false) 41 - const showAccentModal = ref(false) 42 - const showAboutModal = ref(false) 43 - 44 39 const currentThemeLabel = computed(() => { 45 40 if (themeStore.followSystem) return 'System Default' 46 41 return themeStore.activeTheme.name ··· 50 45 const accent = themeStore.preferredAccent 51 46 return accent.charAt(0).toUpperCase() + accent.slice(1) 52 47 }) 53 - 54 - const availableThemes = computed(() => themeStore.themes) 55 - 56 - const selectTheme = (themeId: string, type: 'light' | 'dark') => { 57 - if (type === 'dark') themeStore.setPreferredDark(themeId) 58 - else themeStore.setPreferredLight(themeId) 59 - } 60 48 </script> 61 49 62 50 <template> ··· 76 64 title="Theme Preference" 77 65 :subtitle="currentThemeLabel" 78 66 clickable 79 - @click="showThemeModal = true" 67 + @click="modals.open(ThemeModal)" 80 68 > 81 69 <template #start><IconPaletteOutline /></template> 82 70 <template #end> ··· 101 89 title="Accent Colour" 102 90 :subtitle="currentAccentLabel" 103 91 clickable 104 - @click="showAccentModal = true" 92 + @click="modals.open(AccentModal)" 105 93 > 106 94 <template #start> 107 95 <div ··· 149 137 <template #start><div style="display: flex" v-html="tangledLogo" /></template> 150 138 <template #end><IconOpenInBrowserRounded class="chevron" /></template> 151 139 </ListItem> 152 - <ListItem title="About Bluebell" clickable @click="showAboutModal = true"> 140 + <ListItem title="About Bluebell" clickable @click="modals.open(AboutModal)"> 153 141 <template #start><div style="display: flex" v-html="bluebellLogo" /></template> 154 142 <template #end><IconChevronRightRounded class="chevron" /></template> 155 143 </ListItem> ··· 162 150 </ListGroup> 163 151 164 152 <ListGroup title="Developer"> </ListGroup> 165 - 166 - <!-- modals --> 167 - <Modal v-model:open="showThemeModal" title="Select Theme" width="640px"> 168 - <div class="theme-modal"> 169 - <div class="theme-section"> 170 - <div class="section-header">Light Mode</div> 171 - <div class="palette-grid"> 172 - <button 173 - v-for="t in availableThemes.filter((x) => x.type === 'light')" 174 - :key="t.id" 175 - class="palette-card" 176 - :class="{ active: themeStore.preferredLight === t.id }" 177 - @click="selectTheme(t.id, 'light')" 178 - :style="{ 179 - '--t-base': `hsl(${t.variables.base})`, 180 - '--t-mantle': `hsl(${t.variables.mantle})`, 181 - '--t-crust': `hsl(${t.variables.crust})`, 182 - '--t-overlay': `hsl(${t.variables.overlay})`, 183 - '--t-text': `hsl(${t.variables.text})`, 184 - '--t-surface': `hsl(${t.variables.surface0})`, 185 - '--t-blue': `hsl(${t.variables.blue})`, 186 - '--t-pink': `hsl(${t.variables.pink})`, 187 - '--t-green': `hsl(${t.variables.green})`, 188 - }" 189 - > 190 - <div class="card-bg"></div> 191 - <div class="card-content"> 192 - <span class="theme-name">{{ t.name }}</span> 193 - <div class="color-row"> 194 - <div class="color-chip" style="background-color: var(--t-blue)"></div> 195 - <div class="color-chip" style="background-color: var(--t-pink)"></div> 196 - <div class="color-chip" style="background-color: var(--t-green)"></div> 197 - <div class="color-chip" style="background-color: var(--t-text)"></div> 198 - </div> 199 - </div> 200 - <div class="active-ring" v-if="themeStore.preferredLight === t.id"> 201 - <IconCheckCircleRounded /> 202 - </div> 203 - </button> 204 - </div> 205 - </div> 206 - 207 - <div class="theme-section"> 208 - <div class="section-header">Dark Mode</div> 209 - <div class="palette-grid"> 210 - <button 211 - v-for="t in availableThemes.filter((x) => x.type === 'dark')" 212 - :key="t.id" 213 - class="palette-card" 214 - :class="{ active: themeStore.preferredDark === t.id }" 215 - @click="selectTheme(t.id, 'dark')" 216 - :style="{ 217 - '--t-base': `hsl(${t.variables.base})`, 218 - '--t-mantle': `hsl(${t.variables.mantle})`, 219 - '--t-crust': `hsl(${t.variables.crust})`, 220 - '--t-text': `hsl(${t.variables.text})`, 221 - '--t-surface': `hsl(${t.variables.surface0})`, 222 - '--t-blue': `hsl(${t.variables.blue})`, 223 - '--t-pink': `hsl(${t.variables.pink})`, 224 - '--t-green': `hsl(${t.variables.green})`, 225 - }" 226 - > 227 - <div class="card-bg"></div> 228 - <div class="card-content"> 229 - <span class="theme-name">{{ t.name }}</span> 230 - <div class="color-row"> 231 - <div class="color-chip" style="background-color: var(--t-blue)"></div> 232 - <div class="color-chip" style="background-color: var(--t-pink)"></div> 233 - <div class="color-chip" style="background-color: var(--t-green)"></div> 234 - <div class="color-chip" style="background-color: var(--t-text)"></div> 235 - </div> 236 - </div> 237 - <div class="active-ring" v-if="themeStore.preferredDark === t.id"> 238 - <IconCheckCircleRounded /> 239 - </div> 240 - </button> 241 - </div> 242 - </div> 243 - </div> 244 - <template #footer> 245 - <Button variant="ghost" @click="showThemeModal = false">Done</Button> 246 - </template> 247 - </Modal> 248 - 249 - <Modal v-model:open="showAccentModal" title="Accent Colour" width="640px"> 250 - <div class="accent-modal"> 251 - <button 252 - v-for="colour in AccentColours" 253 - :key="colour" 254 - class="accent-btn" 255 - :class="{ active: themeStore.preferredAccent === colour }" 256 - @click="themeStore.setAccent(colour)" 257 - :aria-label="`Select ${colour} accent`" 258 - > 259 - <div class="accent-preview" :style="{ backgroundColor: `hsl(var(--${colour}))` }"> 260 - <IconCheckRounded class="check-icon" /> 261 - </div> 262 - <span class="accent-name">{{ colour }}</span> 263 - </button> 264 - </div> 265 - <template #footer> 266 - <Button variant="primary" @click="showAccentModal = false">Done</Button> 267 - </template> 268 - </Modal> 269 - 270 - <Modal v-model:open="showAboutModal" title="About" width="480px"> 271 - <div class="about-modal"> 272 - <div class="about-header"> 273 - <div class="about-logo" v-html="bluebellLogo"></div> 274 - <div class="about-title-row"> 275 - <h2 class="app-name">Bluebell</h2> 276 - <span class="version-badge">v{{ appVersion }}</span> 277 - </div> 278 - <p class="about-desc">A beautiful and fast client for the Atmosphere & Bluesky.</p> 279 - </div> 280 - 281 - <div class="about-actions"> 282 - <a 283 - href="https://tangled.sh/vt3e.cat/bluebell" 284 - target="_blank" 285 - rel="noopener" 286 - class="action-card" 287 - > 288 - <IconCodeRounded class="action-icon" /> 289 - <span>Source</span> 290 - </a> 291 - <a 292 - href="https://tangled.sh/vt3e.cat/bluebell/issues" 293 - target="_blank" 294 - rel="noopener" 295 - class="action-card" 296 - > 297 - <IconBugReportRounded class="action-icon" /> 298 - <span>Issues</span> 299 - </a> 300 - </div> 301 - 302 - <div class="about-credits"> 303 - <p> 304 - Created by 305 - <AppLink name="user-profile" :params="{ id: 'vt3e.cat' }" class="credit-link"> 306 - @vt3e.cat 307 - </AppLink> 308 - </p> 309 - <p class="tech-stack"> 310 - Powered by Vue 3, Pinia & ATCute. <br /> 311 - <span class="heart"><IconFavoriteRounded /></span> 312 - </p> 313 - </div> 314 - </div> 315 - <template #footer> 316 - <Button variant="primary" @click="showAboutModal = false">Close</Button> 317 - </template> 318 - </Modal> 319 153 </PageLayout> 320 154 </template> 321 155 ··· 336 170 height: 1.5rem; 337 171 border-radius: 50%; 338 172 border: 2px solid hsla(var(--surface2) / 0.5); 339 - } 340 - 341 - .about-modal { 342 - display: flex; 343 - flex-direction: column; 344 - gap: 2rem; 345 - padding: 0.5rem 0; 346 - text-align: center; 347 - 348 - .about-header { 349 - display: flex; 350 - flex-direction: column; 351 - align-items: center; 352 - gap: 1rem; 353 - 354 - .about-logo { 355 - width: 5rem; 356 - height: 5rem; 357 - color: hsl(var(--accent)); 358 - :deep(svg) { 359 - width: 100%; 360 - height: 100%; 361 - } 362 - } 363 - 364 - .about-title-row { 365 - display: flex; 366 - align-items: center; 367 - gap: 0.75rem; 368 - 369 - .app-name { 370 - font-size: 2rem; 371 - font-weight: 800; 372 - color: hsl(var(--text)); 373 - line-height: 1; 374 - } 375 - 376 - .version-badge { 377 - background: hsla(var(--accent) / 0.1); 378 - color: hsl(var(--accent)); 379 - font-size: 0.75rem; 380 - font-weight: 700; 381 - padding: 0.25rem 0.5rem; 382 - border-radius: 99px; 383 - font-family: monospace; 384 - } 385 - } 386 - 387 - .about-desc { 388 - color: hsl(var(--subtext0)); 389 - font-size: 0.95rem; 390 - line-height: 1.5; 391 - max-width: 300px; 392 - } 393 - } 394 - 395 - .about-actions { 396 - display: flex; 397 - justify-content: center; 398 - gap: 0.75rem; 399 - 400 - .action-card { 401 - display: flex; 402 - flex-direction: column; 403 - align-items: center; 404 - justify-content: center; 405 - gap: 0.5rem; 406 - padding: 1rem; 407 - min-width: 8rem; 408 - background: hsla(var(--surface1) / 0.1); 409 - border-radius: var(--radius-md); 410 - text-decoration: none; 411 - color: hsl(var(--text)); 412 - border: 1px solid transparent; 413 - 414 - .action-icon { 415 - font-size: 1.5rem; 416 - color: hsl(var(--accent)); 417 - } 418 - 419 - span { 420 - font-size: 0.8rem; 421 - font-weight: 600; 422 - } 423 - 424 - &:hover { 425 - background: hsla(var(--surface1) / 0.2); 426 - } 427 - &:active { 428 - background: hsla(var(--surface1) / 0.075); 429 - } 430 - } 431 - } 432 - 433 - .about-credits { 434 - display: flex; 435 - flex-direction: column; 436 - gap: 0.5rem; 437 - font-size: 0.9rem; 438 - color: hsl(var(--subtext0)); 439 - 440 - .credit-link { 441 - color: hsl(var(--text)); 442 - font-weight: 600; 443 - text-decoration: none; 444 - &:hover { 445 - text-decoration: underline; 446 - color: hsl(var(--accent)); 447 - } 448 - } 449 - 450 - .tech-stack { 451 - font-size: 0.8rem; 452 - opacity: 0.7; 453 - } 454 - 455 - .heart { 456 - color: hsl(var(--red)); 457 - display: inline-flex; 458 - vertical-align: middle; 459 - margin-top: 0.25rem; 460 - font-size: 1rem; 461 - } 462 - } 463 - } 464 - 465 - .accent-modal { 466 - display: grid; 467 - grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); 468 - gap: 1rem; 469 - padding-bottom: 1rem; 470 - 471 - .accent-btn { 472 - display: flex; 473 - flex-direction: column; 474 - align-items: center; 475 - gap: 0.5rem; 476 - background: none; 477 - border: none; 478 - cursor: pointer; 479 - padding: 0.5rem; 480 - border-radius: var(--radius-md); 481 - 482 - svg { 483 - opacity: 0; 484 - } 485 - 486 - &:hover { 487 - background-color: hsl(var(--surface0)); 488 - } 489 - 490 - .accent-preview { 491 - width: 3rem; 492 - height: 3rem; 493 - border-radius: 50%; 494 - display: flex; 495 - align-items: center; 496 - justify-content: center; 497 - color: hsl(var(--base)); 498 - 499 - .check-icon { 500 - font-size: 1.75rem; 501 - } 502 - } 503 - 504 - .accent-name { 505 - font-size: 0.75rem; 506 - color: hsl(var(--subtext0)); 507 - text-transform: capitalize; 508 - font-weight: 700; 509 - } 510 - 511 - &.active { 512 - svg { 513 - opacity: 1; 514 - } 515 - .accent-name { 516 - color: hsl(var(--text)); 517 - } 518 - } 519 - } 520 - } 521 - 522 - .theme-modal { 523 - display: flex; 524 - flex-direction: column; 525 - gap: 2rem; 526 - padding-bottom: 1rem; 527 - 528 - .mini-palette { 529 - display: flex; 530 - gap: 4px; 531 - padding: 4px; 532 - border-radius: 99px; 533 - border: 1px solid hsla(var(--text) / 0.1); 534 - 535 - .dot { 536 - width: 0.75rem; 537 - height: 0.75rem; 538 - border-radius: 50%; 539 - } 540 - } 541 - 542 - .section-header { 543 - font-size: 0.75rem; 544 - text-transform: uppercase; 545 - letter-spacing: 0.05em; 546 - font-weight: 700; 547 - color: hsl(var(--subtext0)); 548 - margin-bottom: 0.75rem; 549 - padding-left: 0.25rem; 550 - } 551 - 552 - .palette-grid { 553 - display: grid; 554 - grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); 555 - gap: 1rem; 556 - } 557 - 558 - .palette-card { 559 - position: relative; 560 - display: flex; 561 - flex-direction: column; 562 - border: none; 563 - background: transparent; 564 - padding: 0; 565 - cursor: pointer; 566 - border-radius: var(--radius-md); 567 - overflow: hidden; 568 - text-align: left; 569 - 570 - .card-bg { 571 - position: absolute; 572 - inset: 0; 573 - background-color: var(--t-base); 574 - border: 1px solid hsla(var(--text) / 0.1); 575 - border-radius: var(--radius-md); 576 - } 577 - 578 - .card-content { 579 - position: relative; 580 - z-index: 1; 581 - padding: 1rem; 582 - display: flex; 583 - flex-direction: column; 584 - gap: 0.75rem; 585 - } 586 - 587 - .theme-name { 588 - font-weight: 700; 589 - font-size: 0.9rem; 590 - color: var(--t-text); 591 - } 592 - 593 - .color-row { 594 - display: flex; 595 - gap: 0.5rem; 596 - } 597 - 598 - .color-chip { 599 - width: 1rem; 600 - height: 1rem; 601 - border-radius: 50%; 602 - } 603 - 604 - .active-ring { 605 - position: absolute; 606 - top: 0.5rem; 607 - right: 0.5rem; 608 - z-index: 2; 609 - color: var(--t-blue); 610 - background: var(--t-base); 611 - border-radius: 50%; 612 - width: 1.25rem; 613 - height: 1.25rem; 614 - display: flex; 615 - align-items: center; 616 - justify-content: center; 617 - box-shadow: 0 2px 4px hsla(var(--crust) / 0.1); 618 - } 619 - 620 - &:hover .card-bg { 621 - background-color: var(--t-mantle); 622 - } 623 - &:active .card-bg { 624 - background-color: var(--t-crust); 625 - } 626 - 627 - &.active .card-bg { 628 - border-color: var(--t-blue); 629 - box-shadow: 0 0 0 2px var(--t-blue); 630 - } 631 - } 632 173 } 633 174 </style>