Browse Source
feat: wa status, enhance reply, update ui, multiple webhook
feat: wa status, enhance reply, update ui, multiple webhook
commitpull/239/headacb35b63b7Author: Aldino Kemal <aldinokemal2104@gmail.com> Date: Fri Jan 31 07:10:27 2025 +0700 feat: change multiple basic auth commit993b5188c5Author: Aldino Kemal <aldinokemal2104@gmail.com> Date: Fri Jan 31 07:06:57 2025 +0700 feat: support multiple webhook commit1308039a16Author: Aldino Kemal <aldinokemal2104@gmail.com> Date: Tue Jan 21 19:41:15 2025 +0700 fix: login with code commit1a55ccb3ebAuthor: Aldino Kemal <aldinokemal2104@gmail.com> Date: Tue Jan 21 15:12:45 2025 +0700 feat: add star message commit6a4ef88115Author: Aldino Kemal <aldinokemal2104@gmail.com> Date: Tue Jan 21 14:16:03 2025 +0700 feat: add change avatar api commit52c738d2ceAuthor: Aldino Kemal <aldinokemal2104@gmail.com> Date: Tue Jan 21 12:07:13 2025 +0700 feat: add send presence commit3e4cbf8cb2Author: Aldino Kemal <aldinokemal2104@gmail.com> Date: Tue Jan 21 10:52:05 2025 +0700 fix: reply message commite08ae59992Author: Aldino Kemal <aldinokemal2104@gmail.com> Date: Tue Jan 21 08:32:21 2025 +0700 fix: reply issue commitfa80cf3502Author: Aldino Kemal <aldinokemal2104@gmail.com> Date: Tue Jan 21 05:34:55 2025 +0700 feat: optimize UI validation commitac58b9579eAuthor: Aldino Kemal <aldinokemal2104@gmail.com> Date: Tue Jan 21 00:11:48 2025 +0700 refactor: move isValidForm methods in SendImage, SendMessage, and SendVideo refactor(SendImage.js): move isValidForm method to computed section refactor(SendMessage.js): move isValidForm method to computed section refactor(SendVideo.js): remove errors object and resetErrors call fix(SendVideo.js): change isFormValid to isValidForm and ensure consistency commit1dc30bcd86Author: Aldino Kemal <aldinokemal2104@gmail.com> Date: Mon Jan 20 23:02:26 2025 +0700 feat: optimize UI commite652eabb0aAuthor: Aldino Kemal <aldinokemal2104@gmail.com> Date: Mon Jan 20 21:22:13 2025 +0700 feat: update package
51 changed files with 1711 additions and 233 deletions
-
77docs/openapi.yaml
-
125readme.md
-
16src/cmd/root.go
-
15src/config/settings.go
-
7src/domains/message/message.go
-
5src/domains/send/presence.go
-
1src/domains/send/send.go
-
9src/domains/user/account.go
-
1src/domains/user/user.go
-
13src/go.mod
-
21src/go.sum
-
41src/internal/rest/message.go
-
17src/internal/rest/send.go
-
19src/internal/rest/user.go
-
98src/pkg/utils/chat_storage.go
-
5src/pkg/whatsapp/init.go
-
111src/pkg/whatsapp/utils.go
-
10src/pkg/whatsapp/webhook.go
-
14src/services/app.go
-
27src/services/message.go
-
116src/services/send.go
-
60src/services/user.go
-
15src/validations/message_validation.go
-
13src/validations/send_validation.go
-
282src/views/assets/app.css
-
16src/views/components/AccountAvatar.js
-
113src/views/components/AccountChangeAvatar.js
-
1src/views/components/AccountPrivacy.js
-
16src/views/components/AccountUserInfo.js
-
1src/views/components/AppLogin.js
-
5src/views/components/AppLoginWithCode.js
-
1src/views/components/AppLogout.js
-
1src/views/components/AppReconnect.js
-
20src/views/components/GroupCreate.js
-
23src/views/components/GroupJoinWithLink.js
-
17src/views/components/GroupManageParticipants.js
-
21src/views/components/MessageDelete.js
-
18src/views/components/MessageReact.js
-
18src/views/components/MessageRevoke.js
-
18src/views/components/MessageUpdate.js
-
37src/views/components/SendAudio.js
-
25src/views/components/SendContact.js
-
37src/views/components/SendFile.js
-
60src/views/components/SendImage.js
-
26src/views/components/SendLocation.js
-
50src/views/components/SendMessage.js
-
25src/views/components/SendPoll.js
-
86src/views/components/SendPresence.js
-
64src/views/components/SendVideo.js
-
25src/views/components/generic/FormRecipient.js
-
102src/views/index.html
@ -0,0 +1,5 @@ |
|||
package send |
|||
|
|||
type PresenceRequest struct { |
|||
Type string `json:"type" form:"type"` |
|||
} |
|||
@ -0,0 +1,98 @@ |
|||
package utils |
|||
|
|||
import ( |
|||
"fmt" |
|||
"os" |
|||
"strings" |
|||
|
|||
"github.com/aldinokemal/go-whatsapp-web-multidevice/config" |
|||
"github.com/gofiber/fiber/v2/log" |
|||
) |
|||
|
|||
type RecordedMessage struct { |
|||
MessageID string `json:"message_id,omitempty"` |
|||
JID string `json:"jid,omitempty"` |
|||
MessageContent string `json:"message_content,omitempty"` |
|||
} |
|||
|
|||
func FindRecordFromStorage(messageID string) (RecordedMessage, error) { |
|||
data, err := os.ReadFile(config.PathChatStorage) |
|||
if err != nil { |
|||
return RecordedMessage{}, err |
|||
} |
|||
|
|||
lines := strings.Split(string(data), "\n") |
|||
for _, line := range lines { |
|||
if line == "" { |
|||
continue |
|||
} |
|||
parts := strings.Split(line, ",") |
|||
if len(parts) == 3 && parts[0] == messageID { |
|||
return RecordedMessage{ |
|||
MessageID: parts[0], |
|||
JID: parts[1], |
|||
MessageContent: parts[2], |
|||
}, nil |
|||
} |
|||
} |
|||
return RecordedMessage{}, fmt.Errorf("message ID %s not found in storage", messageID) |
|||
} |
|||
|
|||
func RecordMessage(messageID string, senderJID string, messageContent string) { |
|||
message := RecordedMessage{ |
|||
MessageID: messageID, |
|||
JID: senderJID, |
|||
MessageContent: messageContent, |
|||
} |
|||
|
|||
// Read existing messages
|
|||
var messages []RecordedMessage |
|||
if data, err := os.ReadFile(config.PathChatStorage); err == nil { |
|||
// Split file by newlines and parse each line
|
|||
lines := strings.Split(string(data), "\n") |
|||
for _, line := range lines { |
|||
if line == "" { |
|||
continue |
|||
} |
|||
parts := strings.Split(line, ",") |
|||
|
|||
msg := RecordedMessage{ |
|||
MessageID: parts[0], |
|||
JID: parts[1], |
|||
MessageContent: parts[2], |
|||
} |
|||
messages = append(messages, msg) |
|||
} |
|||
} |
|||
|
|||
// Check for duplicates
|
|||
for _, msg := range messages { |
|||
if msg.MessageID == message.MessageID { |
|||
return // Skip if duplicate found
|
|||
} |
|||
} |
|||
|
|||
// Write new message at the top
|
|||
f, err := os.OpenFile(config.PathChatStorage, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) |
|||
if err != nil { |
|||
log.Errorf("Failed to open received-chat.txt: %v", err) |
|||
return |
|||
} |
|||
defer f.Close() |
|||
|
|||
// Write new message first
|
|||
csvLine := fmt.Sprintf("%s,%s,%s\n", message.MessageID, message.JID, message.MessageContent) |
|||
if _, err := f.WriteString(csvLine); err != nil { |
|||
log.Errorf("Failed to write to received-chat.txt: %v", err) |
|||
return |
|||
} |
|||
|
|||
// Write existing messages after
|
|||
for _, msg := range messages { |
|||
csvLine := fmt.Sprintf("%s,%s,%s\n", msg.MessageID, msg.JID, msg.MessageContent) |
|||
if _, err := f.WriteString(csvLine); err != nil { |
|||
log.Errorf("Failed to write to received-chat.txt: %v", err) |
|||
return |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,282 @@ |
|||
:root { |
|||
--primary-color: #00A884; /* WhatsApp's new brand green */ |
|||
--secondary-color: #008069; /* WhatsApp's darker green */ |
|||
--tertiary-color: #075E54; /* WhatsApp's darkest green */ |
|||
--background-color: #EFEAE2; /* WhatsApp's authentic background */ |
|||
--card-hover-color: #ffffff; |
|||
--text-color: #111B21; /* WhatsApp's text color */ |
|||
--gradient-start: #00A884; |
|||
--gradient-end: #008069; |
|||
--message-background: #FFFFFF; |
|||
--success-green: #00A884; |
|||
} |
|||
|
|||
body { |
|||
background-color: var(--background-color) !important; |
|||
color: var(--text-color) !important; |
|||
font-family: "Helvetica Neue", "Helvetica Neue", Helvetica, Arial, sans-serif !important; |
|||
} |
|||
|
|||
.container { |
|||
padding: 2em 1em !important; |
|||
max-width: 1400px !important; |
|||
margin: 0 auto !important; |
|||
} |
|||
|
|||
.main-header { |
|||
background: var(--primary-color) !important; |
|||
color: white !important; |
|||
padding: 1.5em 2em !important; |
|||
border-radius: 24px !important; |
|||
margin-bottom: 2em !important; |
|||
box-shadow: |
|||
0 20px 40px rgba(0, 168, 132, 0.2), |
|||
inset 0 0 80px rgba(255, 255, 255, 0.15) !important; |
|||
position: relative; |
|||
overflow: hidden; |
|||
backdrop-filter: blur(10px); |
|||
border: 1px solid rgba(255, 255, 255, 0.2); |
|||
transition: all 0.3s ease-in-out; |
|||
} |
|||
|
|||
.main-header::before { |
|||
content: ''; |
|||
position: absolute; |
|||
top: 0; |
|||
left: 0; |
|||
right: 0; |
|||
bottom: 0; |
|||
background: |
|||
radial-gradient( |
|||
circle at top right, |
|||
rgba(255,255,255,0.2) 0%, |
|||
rgba(255,255,255,0) 60% |
|||
), |
|||
linear-gradient( |
|||
45deg, |
|||
rgba(255,255,255,0.1) 0%, |
|||
rgba(255,255,255,0) 70% |
|||
); |
|||
opacity: 0.8; |
|||
z-index: 1; |
|||
} |
|||
|
|||
.main-header::after { |
|||
content: ''; |
|||
position: absolute; |
|||
top: -50%; |
|||
left: -50%; |
|||
right: -50%; |
|||
bottom: -50%; |
|||
background: |
|||
radial-gradient( |
|||
circle, |
|||
rgba(255,255,255,0.1) 0%, |
|||
transparent 70% |
|||
); |
|||
animation: rotate 20s linear infinite; |
|||
z-index: 0; |
|||
} |
|||
|
|||
@keyframes rotate { |
|||
from { transform: rotate(0deg); } |
|||
to { transform: rotate(360deg); } |
|||
} |
|||
|
|||
.main-header .ui.header { |
|||
position: relative; |
|||
z-index: 2; |
|||
margin: 0 !important; |
|||
font-weight: 700 !important; |
|||
font-size: clamp(1.5em, 3vw, 1.8em) !important; |
|||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.2) !important; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
justify-content: center; |
|||
gap: 8px; |
|||
letter-spacing: 0.5px; |
|||
} |
|||
|
|||
.main-header .title-container { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
color: white; |
|||
} |
|||
|
|||
.main-header .whatsapp.icon { |
|||
font-size: 1em !important; |
|||
animation: float 4s ease-in-out infinite; |
|||
backdrop-filter: blur(5px); |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.main-header .whatsapp.icon:hover { |
|||
transform: scale(1.1); |
|||
background: rgba(255, 255, 255, 0.2); |
|||
} |
|||
|
|||
.main-header .version-label { |
|||
margin-top: 4px !important; |
|||
background: rgba(255,255,255,0.12); |
|||
color: white; |
|||
font-size: 0.5em; |
|||
padding: 4px 10px; |
|||
border-radius: 12px; |
|||
backdrop-filter: blur(4px); |
|||
border: 1px solid rgba(255, 255, 255, 0.15); |
|||
transition: all 0.25s ease; |
|||
letter-spacing: 0.5px; |
|||
box-shadow: 0 2px 4px rgba(0,0,0,0.1); |
|||
} |
|||
|
|||
.main-header .version-label:hover { |
|||
background: rgba(255,255,255,0.2); |
|||
transform: translateY(-2px); |
|||
} |
|||
|
|||
@media (min-width: 768px) { |
|||
.main-header { |
|||
padding: 2em !important; |
|||
} |
|||
|
|||
.main-header .ui.header { |
|||
flex-direction: column; |
|||
} |
|||
|
|||
.main-header .version-label { |
|||
margin-top: 4px !important; |
|||
} |
|||
} |
|||
|
|||
@keyframes float { |
|||
0% { transform: translateY(0px) rotate(0deg); } |
|||
50% { transform: translateY(-8px) rotate(5deg); } |
|||
100% { transform: translateY(0px) rotate(0deg); } |
|||
} |
|||
|
|||
.ui.header { |
|||
font-weight: 600 !important; |
|||
color: var(--text-color) !important; |
|||
} |
|||
|
|||
.ui.cards > .card { |
|||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.05) !important; |
|||
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important; |
|||
border-radius: 16px !important; |
|||
margin: 0.7em !important; |
|||
background-color: var(--message-background) !important; |
|||
border: 1px solid rgba(18, 140, 126, 0.1); |
|||
} |
|||
|
|||
.ui.cards > .card:hover { |
|||
transform: translateY(-8px) !important; |
|||
box-shadow: 0 12px 24px rgba(18, 140, 126, 0.15) !important; |
|||
background-color: var(--card-hover-color) !important; |
|||
} |
|||
|
|||
.ui.horizontal.divider { |
|||
font-size: 1.3em !important; |
|||
color: var(--secondary-color) !important; |
|||
margin: 2.5em 0 !important; |
|||
font-weight: 700 !important; |
|||
text-transform: uppercase; |
|||
letter-spacing: 1px; |
|||
} |
|||
|
|||
.ui.success.message { |
|||
border-radius: 16px !important; |
|||
box-shadow: 0 4px 8px rgba(37, 211, 102, 0.1) !important; |
|||
border: 1px solid rgba(37, 211, 102, 0.2) !important; |
|||
background-color: rgba(231, 247, 232, 0.8) !important; |
|||
color: var(--success-green) !important; |
|||
animation: slideIn 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275); |
|||
backdrop-filter: blur(10px); |
|||
} |
|||
|
|||
@keyframes slideIn { |
|||
from { transform: translateX(-30px); opacity: 0; } |
|||
to { transform: translateX(0); opacity: 1; } |
|||
} |
|||
|
|||
.ui.button { |
|||
border-radius: 12px !important; |
|||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275) !important; |
|||
/* background: linear-gradient(135deg, var(--primary-color) 0%, var(--secondary-color) 100%) !important; */ |
|||
background: var(--primary-color) !important; |
|||
color: white !important; |
|||
font-weight: 600 !important; |
|||
letter-spacing: 0.5px; |
|||
} |
|||
|
|||
.ui.button:hover { |
|||
transform: translateY(-3px) !important; |
|||
box-shadow: 0 8px 16px rgba(0, 168, 132, 0.2) !important; |
|||
filter: brightness(0.95); |
|||
} |
|||
|
|||
.ui.form input, .ui.form textarea { |
|||
border-radius: 12px !important; |
|||
border: 2px solid rgba(18, 140, 126, 0.1) !important; |
|||
transition: all 0.3s ease; |
|||
} |
|||
|
|||
.ui.form input:focus, .ui.form textarea:focus { |
|||
border-color: var(--primary-color) !important; |
|||
box-shadow: 0 0 0 3px rgba(0, 168, 132, 0.1) !important; |
|||
} |
|||
|
|||
.ui.toast-container { |
|||
padding: 1.5em !important; |
|||
} |
|||
|
|||
.ui.toast { |
|||
border-radius: 16px !important; |
|||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1) !important; |
|||
background-color: var(--message-background) !important; |
|||
border: 1px solid rgba(18, 140, 126, 0.1); |
|||
} |
|||
|
|||
.ui.grid { |
|||
margin: -0.7em !important; |
|||
} |
|||
|
|||
.ui.grid > .column { |
|||
padding: 0.7em !important; |
|||
} |
|||
|
|||
@keyframes fadeIn { |
|||
from { opacity: 0; transform: translateY(30px); } |
|||
to { opacity: 1; transform: translateY(0); } |
|||
} |
|||
|
|||
.ui.cards { |
|||
animation: fadeIn 0.6s cubic-bezier(0.175, 0.885, 0.32, 1.275); |
|||
} |
|||
|
|||
.ui.modal { |
|||
border-radius: 20px !important; |
|||
background-color: var(--message-background) !important; |
|||
overflow: hidden; |
|||
} |
|||
|
|||
.ui.modal > .header { |
|||
border-radius: 20px 20px 0 0 !important; |
|||
background: var(--gradient-start) !important; |
|||
color: white !important; |
|||
padding: 1.5em !important; |
|||
} |
|||
|
|||
.ui.modal > .actions { |
|||
border-radius: 0 0 20px 20px !important; |
|||
background-color: rgba(248, 248, 248, 0.8) !important; |
|||
backdrop-filter: blur(10px); |
|||
} |
|||
|
|||
.ui.modal > .actions > .ui.button { |
|||
background: var(--primary-color) !important; |
|||
color: white !important; |
|||
font-weight: 600 !important; |
|||
letter-spacing: 0.5px; |
|||
} |
|||
@ -0,0 +1,113 @@ |
|||
export default { |
|||
name: 'AccountChangeAvatar', |
|||
data() { |
|||
return { |
|||
loading: false, |
|||
selected_file: null, |
|||
preview_url: null |
|||
} |
|||
}, |
|||
methods: { |
|||
openModal() { |
|||
$('#modalChangeAvatar').modal({ |
|||
onApprove: function () { |
|||
return false; |
|||
} |
|||
}).modal('show'); |
|||
}, |
|||
isValidForm() { |
|||
return this.selected_file !== null; |
|||
}, |
|||
async handleSubmit() { |
|||
if (!this.isValidForm() || this.loading) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
let response = await this.submitApi() |
|||
showSuccessInfo(response) |
|||
$('#modalChangeAvatar').modal('hide'); |
|||
} catch (err) { |
|||
showErrorInfo(err) |
|||
} |
|||
}, |
|||
async submitApi() { |
|||
this.loading = true; |
|||
try { |
|||
let payload = new FormData(); |
|||
payload.append('avatar', $("#file_avatar")[0].files[0]) |
|||
|
|||
let response = await window.http.post(`/user/avatar`, payload) |
|||
this.handleReset(); |
|||
return response.data.message; |
|||
} catch (error) { |
|||
if (error.response) { |
|||
throw new Error(error.response.data.message); |
|||
} |
|||
throw new Error(error.message); |
|||
} finally { |
|||
this.loading = false; |
|||
} |
|||
}, |
|||
handleReset() { |
|||
this.preview_url = null; |
|||
this.selected_file = null; |
|||
$("#file_avatar").val(''); |
|||
}, |
|||
handleImageChange(event) { |
|||
const file = event.target.files[0]; |
|||
if (file) { |
|||
this.preview_url = URL.createObjectURL(file); |
|||
this.selected_file = file.name; |
|||
} |
|||
} |
|||
}, |
|||
template: `
|
|||
<div class="blue card" @click="openModal()" style="cursor:pointer;"> |
|||
<div class="content"> |
|||
<a class="ui olive right ribbon label">Account</a> |
|||
<div class="header">Change Avatar</div> |
|||
<div class="description"> |
|||
Update your profile picture |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Modal Change Avatar --> |
|||
<div class="ui small modal" id="modalChangeAvatar"> |
|||
<i class="close icon"></i> |
|||
<div class="header"> |
|||
Change Avatar |
|||
</div> |
|||
<div class="content" style="max-height: 70vh; overflow-y: auto;"> |
|||
<div class="ui warning message"> |
|||
<i class="info circle icon"></i> |
|||
Please upload a square image (1:1 aspect ratio) to avoid cropping. |
|||
For best results, use an image at least 400x400 pixels. |
|||
</div> |
|||
|
|||
<form class="ui form"> |
|||
<div class="field" style="padding-bottom: 30px"> |
|||
<label>Avatar Image</label> |
|||
<input type="file" style="display: none" id="file_avatar" accept="image/png,image/jpg,image/jpeg" @change="handleImageChange"/> |
|||
<label for="file_avatar" class="ui positive medium green left floated button" style="color: white"> |
|||
<i class="ui upload icon"></i> |
|||
Upload image |
|||
</label> |
|||
<div v-if="preview_url" style="margin-top: 60px"> |
|||
<img :src="preview_url" style="max-width: 100%; max-height: 300px; object-fit: contain" /> |
|||
</div> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
<div class="actions"> |
|||
<button class="ui approve positive right labeled icon button" |
|||
:class="{'loading': this.loading, 'disabled': !isValidForm() || loading}" |
|||
@click.prevent="handleSubmit"> |
|||
Update Avatar |
|||
<i class="save icon"></i> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
`
|
|||
} |
|||
@ -0,0 +1,86 @@ |
|||
export default { |
|||
name: 'SendPresence', |
|||
data() { |
|||
return { |
|||
type: 'available', |
|||
loading: false, |
|||
} |
|||
}, |
|||
methods: { |
|||
openModal() { |
|||
$('#modalSendPresence').modal({ |
|||
onApprove: function () { |
|||
return false; |
|||
} |
|||
}).modal('show'); |
|||
}, |
|||
async handleSubmit() { |
|||
if (this.loading) { |
|||
return; |
|||
} |
|||
|
|||
try { |
|||
let response = await this.submitApi() |
|||
showSuccessInfo(response) |
|||
$('#modalSendPresence').modal('hide'); |
|||
} catch (err) { |
|||
showErrorInfo(err) |
|||
} |
|||
}, |
|||
async submitApi() { |
|||
this.loading = true; |
|||
try { |
|||
let payload = { |
|||
type: this.type |
|||
} |
|||
let response = await window.http.post(`/send/presence`, payload) |
|||
return response.data.message; |
|||
} catch (error) { |
|||
if (error.response) { |
|||
throw new Error(error.response.data.message); |
|||
} |
|||
throw new Error(error.message); |
|||
} finally { |
|||
this.loading = false; |
|||
} |
|||
} |
|||
}, |
|||
template: `
|
|||
<div class="blue card" @click="openModal()" style="cursor: pointer"> |
|||
<div class="content"> |
|||
<a class="ui blue right ribbon label">Send</a> |
|||
<div class="header">Send Presence</div> |
|||
<div class="description"> |
|||
Set <div class="ui green horizontal label">available</div> or <div class="ui grey horizontal label">unavailable</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
<!-- Modal SendPresence --> |
|||
<div class="ui small modal" id="modalSendPresence"> |
|||
<i class="close icon"></i> |
|||
<div class="header"> |
|||
Send Presence |
|||
</div> |
|||
<div class="content"> |
|||
<form class="ui form"> |
|||
<div class="field"> |
|||
<label>Presence Status</label> |
|||
<select v-model="type" class="ui dropdown"> |
|||
<option value="available">Available</option> |
|||
<option value="unavailable">Unavailable</option> |
|||
</select> |
|||
</div> |
|||
</form> |
|||
</div> |
|||
<div class="actions"> |
|||
<button class="ui approve positive right labeled icon button" |
|||
:class="{'loading': loading, 'disabled': loading}" |
|||
@click.prevent="handleSubmit"> |
|||
Send |
|||
<i class="send icon"></i> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
`
|
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue