Skip to content

Commit 5ee2169

Browse files
authored
feat(frontend): add remaining characters counter (#507)
* feat(frontend): add remaining characters counter * fix: build --------- Co-authored-by: Lucio Rubens <luciorubeens@users.noreply.github.com>
1 parent b44d4e2 commit 5ee2169

File tree

4 files changed

+143
-94
lines changed

4 files changed

+143
-94
lines changed

packages/frontend-main/src/components/popups/NewPostDialog.vue

Lines changed: 10 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,25 @@
11
<script lang="ts" setup>
2-
import { Decimal } from '@cosmjs/math';
3-
import { computed, ref } from 'vue';
2+
import { ref } from 'vue';
43
import { toast } from 'vue-sonner';
54
6-
import PostEditorToolbar from '@/components/posts/PostEditorToolbar.vue';
7-
import PostMediaThumbnail from '@/components/posts/PostMediaThumbnail.vue';
8-
import
9-
{ Button }
10-
from '@/components/ui/button';
5+
import PostComposer from '@/components/posts/PostComposer.vue';
116
import {
127
Dialog,
138
DialogTitle,
149
ResponsiveDialogContent,
1510
} from '@/components/ui/dialog';
16-
import InputPhoton from '@/components/ui/input/InputPhoton.vue';
17-
import { Textarea } from '@/components/ui/textarea';
1811
import { useConfirmDialog } from '@/composables/useConfirmDialog';
1912
import { useCreatePost } from '@/composables/useCreatePost';
2013
import { useTxDialog } from '@/composables/useTxDialog';
21-
import { useConfigStore } from '@/stores/useConfigStore';
22-
import { fractionalDigits } from '@/utility/atomics';
2314
import { showBroadcastingToast } from '@/utility/toast';
2415
2516
const MAX_CHARS = 512 - 'dither.Post("")'.length;
2617
const message = ref('');
27-
const isBalanceInputValid = ref(false);
2818
2919
const { createPost, txError, txSuccess } = useCreatePost();
3020
const { showConfirmDialog } = useConfirmDialog();
3121
32-
const { isShown, inputPhotonModel, handleClose } = useTxDialog<object>('newPost', txSuccess, txError);
33-
const configStore = useConfigStore();
34-
const amountAtomics = computed(() => configStore.config.defaultAmountEnabled ? configStore.config.defaultAmountAtomics : Decimal.fromUserInput(inputPhotonModel.value.toString(), fractionalDigits).atomics);
35-
36-
function handleInputValidity(value: boolean) {
37-
isBalanceInputValid.value = value;
38-
}
39-
40-
const canSubmit = computed(() => {
41-
return isBalanceInputValid.value && message.value.length > 0;
42-
});
22+
const { isShown, handleClose } = useTxDialog<object>('newPost', txSuccess, txError);
4323
4424
function handleCloseWithSaveDraft() {
4525
handleClose();
@@ -57,12 +37,7 @@ function handleCloseWithSaveDraft() {
5737
});
5838
}
5939
60-
async function handleSubmit() {
61-
if (!canSubmit.value) {
62-
return;
63-
}
64-
65-
const msgValue = message.value;
40+
async function handleSubmit({ message: msgValue, amountAtomics }: { message: string; amountAtomics: string }) {
6641
message.value = '';
6742
handleClose();
6843
@@ -71,25 +46,12 @@ async function handleSubmit() {
7146
try {
7247
await createPost({
7348
message: msgValue,
74-
amountAtomics: amountAtomics.value,
49+
amountAtomics,
7550
});
7651
} finally {
7752
toast.dismiss(toastId);
7853
}
7954
}
80-
81-
function handleInsertText(text: string) {
82-
// Ensure there's a space before inserting new text
83-
if (message.value.length > 0 && !message.value.endsWith(' ')) {
84-
message.value += ' ';
85-
}
86-
87-
message.value += text;
88-
}
89-
90-
function handleRemoveText(text: string) {
91-
message.value = message.value.replace(text, '').trim();
92-
}
9355
</script>
9456

9557
<template>
@@ -98,21 +60,12 @@ function handleRemoveText(text: string) {
9860
<ResponsiveDialogContent>
9961
<DialogTitle>{{ $t('components.PopupTitles.newPost') }}</DialogTitle>
10062

101-
<Textarea
102-
v-model="message" :placeholder="$t('placeholders.post')" :maxlength="MAX_CHARS" class="min-h-[74px] w-full break-all"
63+
<PostComposer
64+
v-model="message"
65+
:max-chars="MAX_CHARS"
66+
:placeholder="$t('placeholders.post')"
67+
@submit="handleSubmit"
10368
/>
104-
105-
<PostMediaThumbnail :content="message" @remove-text="handleRemoveText" />
106-
107-
<PostEditorToolbar :content="message" @insert-text="handleInsertText" />
108-
109-
<!-- Transaction Form -->
110-
<div class="flex flex-col w-full gap-4">
111-
<InputPhoton v-if="!configStore.config.defaultAmountEnabled" v-model="inputPhotonModel" @on-validity-change="handleInputValidity" />
112-
<Button class="w-full" :disabled="!canSubmit" @click="handleSubmit">
113-
{{ $t('components.Button.submit') }}
114-
</Button>
115-
</div>
11669
</ResponsiveDialogContent>
11770
</Dialog>
11871
</div>

packages/frontend-main/src/components/popups/ReplyDialog.vue

Lines changed: 19 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,60 @@
11
<script lang="ts" setup>
22
import type { Post } from 'api-main/types/feed';
33
4-
import { Decimal } from '@cosmjs/math';
5-
import { computed, ref } from 'vue';
4+
import { ref } from 'vue';
65
import { toast } from 'vue-sonner';
76
7+
import PostComposer from '@/components/posts/PostComposer.vue';
88
import PostMessage from '@/components/posts/PostMessage.vue';
99
import PrettyTimestamp from '@/components/posts/PrettyTimestamp.vue';
10-
import { Button } from '@/components/ui/button';
1110
import { Dialog, DialogTitle, ResponsiveDialogContent } from '@/components/ui/dialog';
12-
import InputPhoton from '@/components/ui/input/InputPhoton.vue';
13-
import { Textarea } from '@/components/ui/textarea';
1411
import UserAvatar from '@/components/users/UserAvatar.vue';
1512
import Username from '@/components/users/Username.vue';
1613
import { useCreateReply } from '@/composables/useCreateReply';
1714
import { useTxDialog } from '@/composables/useTxDialog';
18-
import { useConfigStore } from '@/stores/useConfigStore';
19-
import { fractionalDigits } from '@/utility/atomics';
2015
import { showBroadcastingToast } from '@/utility/toast';
2116
2217
const POST_HASH_LEN = 64;
2318
const MAX_CHARS = 512 - ('dither.Reply("", "")'.length + POST_HASH_LEN);
2419
const message = ref('');
25-
const isBalanceInputValid = ref(false);
26-
const configStore = useConfigStore();
2720
2821
const { createReply, txError, txSuccess } = useCreateReply();
2922
const {
3023
isShown,
31-
inputPhotonModel,
3224
popupState: reply,
3325
handleClose,
3426
} = useTxDialog<Post>('reply', txSuccess, txError);
35-
const amountAtomics = computed(() => configStore.config.defaultAmountEnabled ? configStore.config.defaultAmountAtomics : Decimal.fromUserInput(inputPhotonModel.value.toString(), fractionalDigits).atomics);
36-
const canSubmit = computed(() => configStore.config.defaultAmountEnabled || (isBalanceInputValid.value && message.value.length > 0));
3727
38-
async function handleSubmit() {
39-
if (!canSubmit.value || !reply.value) {
28+
function handleCloseWithCleanup() {
29+
message.value = '';
30+
handleClose();
31+
}
32+
33+
async function handleSubmit({ message: msgValue, amountAtomics }: { message: string; amountAtomics: string }) {
34+
if (!reply.value) {
4035
return;
4136
}
4237
4338
const parentPost = ref(reply.value);
39+
message.value = '';
4440
handleClose();
4541
const toastId = showBroadcastingToast('Reply');
4642
4743
try {
48-
await createReply({ parentPost, message: message.value, amountAtomics: amountAtomics.value });
44+
await createReply({ parentPost, message: msgValue, amountAtomics });
4945
} finally {
5046
toast.dismiss(toastId);
5147
}
5248
}
53-
54-
function handleInputValidity(value: boolean) {
55-
isBalanceInputValid.value = value;
56-
}
5749
</script>
5850

5951
<template>
6052
<div>
61-
<Dialog v-if="isShown" open @update:open="handleClose">
53+
<Dialog v-if="isShown" open @update:open="handleCloseWithCleanup">
6254
<ResponsiveDialogContent>
6355
<DialogTitle>{{ $t('components.PopupTitles.reply') }}</DialogTitle>
6456

57+
<!-- Parent Post Display -->
6558
<div class="flex flex-row gap-3 border-b pb-3">
6659
<UserAvatar :user-address="reply.author" />
6760
<div class="flex flex-col w-full gap-3">
@@ -76,16 +69,12 @@ function handleInputValidity(value: boolean) {
7669
</div>
7770
</div>
7871

79-
<Textarea v-model="message" :placeholder="$t('placeholders.reply')" :maxlength="MAX_CHARS" />
80-
81-
<!-- Transaction Form -->
82-
<div class="flex flex-col w-full gap-4">
83-
<InputPhoton v-if="!configStore.config.defaultAmountEnabled" v-model="inputPhotonModel" @on-validity-change="handleInputValidity" />
84-
<span v-if="txError" class="text-red-500 text-xs">{{ txError }}</span>
85-
<Button class="w-full" :disabled="!canSubmit" @click="handleSubmit">
86-
{{ $t('components.Button.submit') }}
87-
</Button>
88-
</div>
72+
<PostComposer
73+
v-model="message"
74+
:max-chars="MAX_CHARS"
75+
:placeholder="$t('placeholders.reply')"
76+
@submit="handleSubmit"
77+
/>
8978
</ResponsiveDialogContent>
9079
</Dialog>
9180
</div>
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<script lang="ts" setup>
2+
import { Decimal } from '@cosmjs/math';
3+
import { computed, nextTick, onMounted, ref } from 'vue';
4+
5+
import PostEditorToolbar from '@/components/posts/PostEditorToolbar.vue';
6+
import PostMediaThumbnail from '@/components/posts/PostMediaThumbnail.vue';
7+
import { Button } from '@/components/ui/button';
8+
import InputPhoton from '@/components/ui/input/InputPhoton.vue';
9+
import { Textarea } from '@/components/ui/textarea';
10+
import { useConfigStore } from '@/stores/useConfigStore';
11+
import { fractionalDigits } from '@/utility/atomics';
12+
13+
const props = defineProps<{
14+
maxChars: number;
15+
placeholder: string;
16+
}>();
17+
18+
const emit = defineEmits<{
19+
submit: [{ message: string; amountAtomics: string }];
20+
}>();
21+
22+
const message = defineModel<string>({ required: true });
23+
24+
const isBalanceInputValid = ref(false);
25+
const configStore = useConfigStore();
26+
const inputPhotonModel = ref('0.1');
27+
const textareaRef = ref<InstanceType<typeof Textarea>>();
28+
29+
const amountAtomics = computed(() => {
30+
if (configStore.config.defaultAmountEnabled) {
31+
return configStore.config.defaultAmountAtomics;
32+
}
33+
return Decimal.fromUserInput(inputPhotonModel.value.toString(), fractionalDigits).atomics;
34+
});
35+
36+
const canSubmit = computed(() => {
37+
return isBalanceInputValid.value && message.value.length > 0 && message.value.length <= props.maxChars;
38+
});
39+
40+
const remainingChars = computed(() => props.maxChars - message.value.length);
41+
42+
function handleInputValidity(value: boolean) {
43+
isBalanceInputValid.value = value;
44+
}
45+
46+
function handleInsertText(text: string) {
47+
// Ensure there's a space before inserting new text
48+
if (message.value.length > 0 && !message.value.endsWith(' ')) {
49+
message.value += ' ';
50+
}
51+
52+
message.value += text;
53+
}
54+
55+
function handleRemoveText(text: string) {
56+
message.value = message.value.replace(text, '').trim();
57+
}
58+
59+
function handleSubmit() {
60+
if (!canSubmit.value) {
61+
return;
62+
}
63+
64+
emit('submit', {
65+
message: message.value,
66+
amountAtomics: amountAtomics.value,
67+
});
68+
}
69+
70+
onMounted(async () => {
71+
await nextTick();
72+
const textarea = textareaRef.value?.$el as HTMLTextAreaElement | undefined;
73+
textarea?.focus();
74+
});
75+
76+
defineExpose({
77+
reset: () => {
78+
message.value = '';
79+
},
80+
});
81+
</script>
82+
83+
<template>
84+
<div class="flex flex-col gap-4">
85+
<Textarea
86+
ref="textareaRef"
87+
v-model="message" :placeholder="placeholder" class="min-h-[74px] max-h-[270px] md:max-h-[300px] w-full break-all overflow-y-auto resize-none"
88+
/>
89+
90+
<PostMediaThumbnail :content="message" @remove-text="handleRemoveText" />
91+
92+
<div class="flex items-center justify-between border-t pt-2">
93+
<PostEditorToolbar :content="message" @insert-text="handleInsertText" />
94+
<span class="text-[11px]" :class="remainingChars < 0 ? 'text-red-500' : 'text-muted-foreground'">
95+
{{ remainingChars }}
96+
</span>
97+
</div>
98+
99+
<!-- Transaction Form -->
100+
<div class="flex flex-col w-full gap-4">
101+
<InputPhoton v-if="!configStore.config.defaultAmountEnabled" v-model="inputPhotonModel" @on-validity-change="handleInputValidity" />
102+
<Button class="w-full" :disabled="!canSubmit" @click="handleSubmit">
103+
{{ $t('components.Button.submit') }}
104+
</Button>
105+
</div>
106+
</div>
107+
</template>

packages/frontend-main/src/components/posts/PostEditorToolbar.vue

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,18 +53,18 @@ function insertVideoUrl() {
5353
</script>
5454

5555
<template>
56-
<div class="flex gap-2 items-center border-t pt-2">
56+
<div class="flex gap-2 items-center">
5757
<!-- Image URL Popover -->
5858
<ResponsivePopoverDialog v-model:open="isImagePopoverOpen" modal>
5959
<template #trigger>
6060
<Button
6161
variant="ghost"
62-
size="icon"
62+
size="sm"
6363
type="button"
6464
:title="$t('components.PostEditorToolbar.insertImageTitle')"
65-
:class="{ 'text-blue-500': hasImageInContent }"
65+
class="px-1.5 py-1.5 h-auto" :class="[{ 'text-blue-500': hasImageInContent }]"
6666
>
67-
<Image class="size-5" />
67+
<Image class="size-4" />
6868
</Button>
6969
</template>
7070
<div class="flex flex-col gap-2">
@@ -93,12 +93,12 @@ function insertVideoUrl() {
9393
<template #trigger>
9494
<Button
9595
variant="ghost"
96-
size="icon"
96+
size="sm"
9797
type="button"
9898
:title="$t('components.PostEditorToolbar.insertVideoTitle')"
99-
:class="{ 'text-blue-500': hasVideoInContent }"
99+
class="px-1.5 py-1.5 h-auto" :class="[{ 'text-blue-500': hasVideoInContent }]"
100100
>
101-
<Video class="size-5" />
101+
<Video class="size-4" />
102102
</Button>
103103
</template>
104104
<div class="flex flex-col gap-2">

0 commit comments

Comments
 (0)