diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..2682d811c3c7440d2f6736ac56e9fd0c339108b6 --- /dev/null +++ b/Makefile @@ -0,0 +1,39 @@ +.PHONY: build test run clean lint fmt vet + +BINARY_NAME=matcha +BUILD_DIR=bin + +generate_gif: + alias matcha="go run ." + vhs demo.tape + mv demo.gif public/assets/demo.gif + +build: + go build -o $(BUILD_DIR)/$(BINARY_NAME) . + +run: + go run . + +test: + go test ./... + +test-verbose: + go test -v ./... + +test-coverage: + go test -coverprofile=coverage.out ./... + go tool cover -html=coverage.out -o coverage.html + +clean: + rm -rf $(BUILD_DIR) + rm -f coverage.out coverage.html + +fmt: + go fmt ./... + +vet: + go vet ./... + +lint: fmt vet + +all: lint test build diff --git a/demo.tape b/demo.tape index 00954a1e971db43b9d66fb893597f46e3eac23e7..bf62fd7f6d15eaa2c8d0588085f32680d97f2146 100644 --- a/demo.tape +++ b/demo.tape @@ -25,10 +25,11 @@ Set BorderRadius 10 Sleep 500ms -# Show the command being typed -Type "matcha" -Sleep 500ms +# Show the command being typed but actually run "go run ." +Hide +Type "go run ." Enter +Show # Wait for app to load and show main menu Sleep 2s @@ -53,6 +54,8 @@ Sleep 400ms Up Sleep 400ms Up +Sleep 400ms +Up Sleep 600ms # ============================================ @@ -117,11 +120,12 @@ Down Sleep 400ms Down Sleep 400ms +Down +Sleep 400ms Enter -Sleep 1.5s # Show settings briefly -Sleep 1s +Sleep 1.5s # Go back to menu Escape diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 7ce8ca9f9082cfc8ba94266f58c9295b1a660519..753bc2b2d4ec5841b0b40660191b52b6d7212a00 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -685,3 +685,258 @@ func ArchiveEmail(account *config.Account, uid uint32) error { func ArchiveSentEmail(account *config.Account, uid uint32) error { return ArchiveEmailFromMailbox(account, getSentMailbox(account), uid) } + +// getTrashMailbox returns the trash mailbox name for the account +func getTrashMailbox(account *config.Account) string { + switch account.ServiceProvider { + case "gmail": + return "[Gmail]/Trash" + case "icloud": + return "Deleted Messages" + default: + return "Trash" + } +} + +// getArchiveMailbox returns the archive/all mail mailbox name for the account +func getArchiveMailbox(account *config.Account) string { + switch account.ServiceProvider { + case "gmail": + return "[Gmail]/All Mail" + case "icloud": + return "Archive" + default: + return "Archive" + } +} + +// FetchTrashEmails fetches emails from the trash folder +func FetchTrashEmails(account *config.Account, limit, offset uint32) ([]Email, error) { + c, err := connect(account) + if err != nil { + return nil, err + } + defer c.Logout() + + // Try to find trash by attribute first + trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr) + if err != nil { + // Fallback to hardcoded path + trashMailbox = getTrashMailbox(account) + } + + return FetchMailboxEmails(account, trashMailbox, limit, offset) +} + +// FetchArchiveEmails fetches emails from the archive/all mail folder +// Archive contains all emails, so we match where user is sender OR recipient +func FetchArchiveEmails(account *config.Account, limit, offset uint32) ([]Email, error) { + c, err := connect(account) + if err != nil { + return nil, err + } + defer c.Logout() + + // Try to find archive by attribute first (Gmail uses \All) + archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr) + if err != nil { + // Fallback to hardcoded path + archiveMailbox = getArchiveMailbox(account) + } + + mbox, err := c.Select(archiveMailbox, false) + if err != nil { + return nil, err + } + + if mbox.Messages == 0 { + return []Email{}, nil + } + + to := mbox.Messages - offset + from := uint32(1) + if to > limit { + from = to - limit + 1 + } + + if to < 1 { + return []Email{}, nil + } + + seqset := new(imap.SeqSet) + seqset.AddRange(from, to) + + messages := make(chan *imap.Message, limit) + done := make(chan error, 1) + fetchItems := []imap.FetchItem{imap.FetchEnvelope, imap.FetchUid} + go func() { + done <- c.Fetch(seqset, fetchItems, messages) + }() + + var msgs []*imap.Message + for msg := range messages { + msgs = append(msgs, msg) + } + + if err := <-done; err != nil { + return nil, err + } + + // Determine which email to filter on: prefer Account.FetchEmail, fallback to Account.Email + fetchEmail := strings.ToLower(strings.TrimSpace(account.FetchEmail)) + if fetchEmail == "" { + fetchEmail = strings.ToLower(strings.TrimSpace(account.Email)) + } + + var emails []Email + for _, msg := range msgs { + if msg == nil || msg.Envelope == nil { + continue + } + + var fromAddr string + if len(msg.Envelope.From) > 0 { + fromAddr = msg.Envelope.From[0].Address() + } + + var toAddrList []string + for _, addr := range msg.Envelope.To { + toAddrList = append(toAddrList, addr.Address()) + } + for _, addr := range msg.Envelope.Cc { + toAddrList = append(toAddrList, addr.Address()) + } + + // For archive/All Mail, match emails where user is sender OR recipient + matched := false + // Check if user is the sender + if strings.EqualFold(strings.TrimSpace(fromAddr), fetchEmail) { + matched = true + } + // Check if user is a recipient + if !matched { + for _, r := range toAddrList { + if strings.EqualFold(strings.TrimSpace(r), fetchEmail) { + matched = true + break + } + } + } + + if !matched { + continue + } + + emails = append(emails, Email{ + UID: msg.Uid, + From: fromAddr, + To: toAddrList, + Subject: decodeHeader(msg.Envelope.Subject), + Date: msg.Envelope.Date, + AccountID: account.ID, + }) + } + + // Reverse to get newest first + for i, j := 0, len(emails)-1; i < j; i, j = i+1, j-1 { + emails[i], emails[j] = emails[j], emails[i] + } + + return emails, nil +} + +// FetchTrashEmailBody fetches the body of an email from trash +func FetchTrashEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) { + c, err := connect(account) + if err != nil { + return "", nil, err + } + defer c.Logout() + + trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr) + if err != nil { + trashMailbox = getTrashMailbox(account) + } + + return FetchEmailBodyFromMailbox(account, trashMailbox, uid) +} + +// FetchArchiveEmailBody fetches the body of an email from archive +func FetchArchiveEmailBody(account *config.Account, uid uint32) (string, []Attachment, error) { + c, err := connect(account) + if err != nil { + return "", nil, err + } + defer c.Logout() + + archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr) + if err != nil { + archiveMailbox = getArchiveMailbox(account) + } + + return FetchEmailBodyFromMailbox(account, archiveMailbox, uid) +} + +// FetchTrashAttachment fetches an attachment from trash +func FetchTrashAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) { + c, err := connect(account) + if err != nil { + return nil, err + } + defer c.Logout() + + trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr) + if err != nil { + trashMailbox = getTrashMailbox(account) + } + + return FetchAttachmentFromMailbox(account, trashMailbox, uid, partID, encoding) +} + +// FetchArchiveAttachment fetches an attachment from archive +func FetchArchiveAttachment(account *config.Account, uid uint32, partID string, encoding string) ([]byte, error) { + c, err := connect(account) + if err != nil { + return nil, err + } + defer c.Logout() + + archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr) + if err != nil { + archiveMailbox = getArchiveMailbox(account) + } + + return FetchAttachmentFromMailbox(account, archiveMailbox, uid, partID, encoding) +} + +// DeleteTrashEmail permanently deletes an email from trash +func DeleteTrashEmail(account *config.Account, uid uint32) error { + c, err := connect(account) + if err != nil { + return err + } + defer c.Logout() + + trashMailbox, err := getMailboxByAttr(c, imap.TrashAttr) + if err != nil { + trashMailbox = getTrashMailbox(account) + } + + return DeleteEmailFromMailbox(account, trashMailbox, uid) +} + +// DeleteArchiveEmail deletes an email from archive (moves to trash) +func DeleteArchiveEmail(account *config.Account, uid uint32) error { + c, err := connect(account) + if err != nil { + return err + } + defer c.Logout() + + archiveMailbox, err := getMailboxByAttr(c, imap.AllAttr) + if err != nil { + archiveMailbox = getArchiveMailbox(account) + } + + return DeleteEmailFromMailbox(account, archiveMailbox, uid) +} diff --git a/main.go b/main.go index bdffa9c4758e5cca29fd35affabaa18bebab69b3..5e9039073a4692c13a41650a22c369323cb86144 100644 --- a/main.go +++ b/main.go @@ -65,8 +65,13 @@ type mainModel struct { emailsByAcct map[string][]fetcher.Email sentEmails []fetcher.Email sentByAcct map[string][]fetcher.Email + trashEmails []fetcher.Email + trashByAcct map[string][]fetcher.Email + archiveEmails []fetcher.Email + archiveByAcct map[string][]fetcher.Email inbox *tui.Inbox sentInbox *tui.Inbox + trashArchive *tui.TrashArchive width int height int err error @@ -74,8 +79,10 @@ type mainModel struct { func newInitialModel(cfg *config.Config) *mainModel { initialModel := &mainModel{ - emailsByAcct: make(map[string][]fetcher.Email), - sentByAcct: make(map[string][]fetcher.Email), + emailsByAcct: make(map[string][]fetcher.Email), + sentByAcct: make(map[string][]fetcher.Email), + trashByAcct: make(map[string][]fetcher.Email), + archiveByAcct: make(map[string][]fetcher.Email), } if cfg == nil || !cfg.HasAccounts() { @@ -112,7 +119,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch m.current.(type) { case *tui.FilePicker: return m, func() tea.Msg { return tui.CancelFilePickerMsg{} } - case *tui.Inbox, *tui.Login: + case *tui.Inbox, *tui.Login, *tui.TrashArchive: m.current = tui.NewChoice() return m, m.current.Init() } @@ -138,6 +145,11 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = m.inbox return m, nil } + case tui.MailboxTrash, tui.MailboxArchive: + if m.trashArchive != nil { + m.current = m.trashArchive + return m, nil + } } m.current = tui.NewChoice() return m, nil @@ -226,6 +238,18 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current = tui.NewStatus("Fetching sent emails from all accounts...") return m, tea.Batch(m.current.Init(), fetchAllAccountsEmails(m.config, tui.MailboxSent)) + case tui.GoToTrashArchiveMsg: + if m.config == nil || !m.config.HasAccounts() { + m.current = tui.NewLogin() + return m, m.current.Init() + } + m.current = tui.NewStatus("Fetching trash and archive emails...") + return m, tea.Batch( + m.current.Init(), + fetchAllAccountsEmails(m.config, tui.MailboxTrash), + fetchAllAccountsEmails(m.config, tui.MailboxArchive), + ) + case tui.CachedEmailsLoadedMsg: if msg.Cache == nil { // Cache load failed, fetch normally @@ -305,6 +329,36 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.current.Init() } + if msg.Mailbox == tui.MailboxTrash { + m.trashByAcct = msg.EmailsByAccount + m.trashEmails = flattenAndSort(msg.EmailsByAccount) + + // Create or update trash/archive view + if m.trashArchive == nil { + m.trashArchive = tui.NewTrashArchive(m.trashEmails, m.archiveEmails, m.config.Accounts) + } else { + m.trashArchive.SetTrashEmails(m.trashEmails, m.config.Accounts) + } + m.current = m.trashArchive + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + } + + if msg.Mailbox == tui.MailboxArchive { + m.archiveByAcct = msg.EmailsByAccount + m.archiveEmails = flattenAndSort(msg.EmailsByAccount) + + // Create or update trash/archive view + if m.trashArchive == nil { + m.trashArchive = tui.NewTrashArchive(m.trashEmails, m.archiveEmails, m.config.Accounts) + } else { + m.trashArchive.SetArchiveEmails(m.archiveEmails, m.config.Accounts) + } + m.current = m.trashArchive + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + } + m.emailsByAcct = msg.EmailsByAccount m.emails = flattenAndSort(msg.EmailsByAccount) @@ -358,7 +412,7 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch( func() tea.Msg { return tui.FetchingMoreEmailsMsg{} }, - fetchEmails(account, paginationLimit, msg.Offset, msg.Mailbox), + fetchEmailsForMailbox(account, paginationLimit, msg.Offset, msg.Mailbox), ) case tui.EmailsAppendedMsg: @@ -370,6 +424,22 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.sentEmails = append(m.sentEmails, msg.Emails...) return m, nil } + if msg.Mailbox == tui.MailboxTrash { + if m.trashByAcct == nil { + m.trashByAcct = make(map[string][]fetcher.Email) + } + m.trashByAcct[msg.AccountID] = append(m.trashByAcct[msg.AccountID], msg.Emails...) + m.trashEmails = append(m.trashEmails, msg.Emails...) + return m, nil + } + if msg.Mailbox == tui.MailboxArchive { + if m.archiveByAcct == nil { + m.archiveByAcct = make(map[string][]fetcher.Email) + } + m.archiveByAcct[msg.AccountID] = append(m.archiveByAcct[msg.AccountID], msg.Emails...) + m.archiveEmails = append(m.archiveEmails, msg.Emails...) + return m, nil + } // Inbox if m.emailsByAcct == nil { m.emailsByAcct = make(map[string][]fetcher.Email) @@ -629,10 +699,19 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err != nil { log.Printf("Action failed: %v", msg.Err) m.previousModel = m.current - if msg.Mailbox == tui.MailboxSent && m.sentInbox != nil { - m.previousModel = m.sentInbox - } else if m.inbox != nil { - m.previousModel = m.inbox + switch msg.Mailbox { + case tui.MailboxSent: + if m.sentInbox != nil { + m.previousModel = m.sentInbox + } + case tui.MailboxTrash, tui.MailboxArchive: + if m.trashArchive != nil { + m.previousModel = m.trashArchive + } + default: + if m.inbox != nil { + m.previousModel = m.inbox + } } m.current = tui.NewStatus(fmt.Sprintf("Error: %v", msg.Err)) return m, tea.Tick(2*time.Second, func(t time.Time) tea.Msg { @@ -654,6 +733,17 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.current.Init() } + if msg.Mailbox == tui.MailboxTrash || msg.Mailbox == tui.MailboxArchive { + if m.trashArchive != nil { + m.trashArchive.RemoveEmail(msg.UID, msg.AccountID, msg.Mailbox) + m.current = m.trashArchive + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + } + m.current = tui.NewChoice() + return m, m.current.Init() + } + if m.inbox != nil { m.inbox.RemoveEmail(msg.UID, msg.AccountID) m.current = m.inbox @@ -727,6 +817,14 @@ func (m *mainModel) getEmailByIndex(index int, mailbox tui.MailboxKind) *fetcher if index >= 0 && index < len(m.sentEmails) { return &m.sentEmails[index] } + case tui.MailboxTrash: + if index >= 0 && index < len(m.trashEmails) { + return &m.trashEmails[index] + } + case tui.MailboxArchive: + if index >= 0 && index < len(m.archiveEmails) { + return &m.archiveEmails[index] + } default: if index >= 0 && index < len(m.emails) { return &m.emails[index] @@ -743,6 +841,18 @@ func (m *mainModel) getEmailByUIDAndAccount(uid uint32, accountID string, mailbo return &m.sentEmails[i] } } + case tui.MailboxTrash: + for i := range m.trashEmails { + if m.trashEmails[i].UID == uid && m.trashEmails[i].AccountID == accountID { + return &m.trashEmails[i] + } + } + case tui.MailboxArchive: + for i := range m.archiveEmails { + if m.archiveEmails[i].UID == uid && m.archiveEmails[i].AccountID == accountID { + return &m.archiveEmails[i] + } + } default: for i := range m.emails { if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { @@ -761,6 +871,18 @@ func (m *mainModel) getEmailIndex(uid uint32, accountID string, mailbox tui.Mail return i } } + case tui.MailboxTrash: + for i := range m.trashEmails { + if m.trashEmails[i].UID == uid && m.trashEmails[i].AccountID == accountID { + return i + } + } + case tui.MailboxArchive: + for i := range m.archiveEmails { + if m.archiveEmails[i].UID == uid && m.archiveEmails[i].AccountID == accountID { + return i + } + } default: for i := range m.emails { if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { @@ -790,6 +912,40 @@ func (m *mainModel) updateEmailBodyByUID(uid uint32, accountID string, mailbox t } } } + case tui.MailboxTrash: + for i := range m.trashEmails { + if m.trashEmails[i].UID == uid && m.trashEmails[i].AccountID == accountID { + m.trashEmails[i].Body = body + m.trashEmails[i].Attachments = attachments + break + } + } + if emails, ok := m.trashByAcct[accountID]; ok { + for i := range emails { + if emails[i].UID == uid { + emails[i].Body = body + emails[i].Attachments = attachments + break + } + } + } + case tui.MailboxArchive: + for i := range m.archiveEmails { + if m.archiveEmails[i].UID == uid && m.archiveEmails[i].AccountID == accountID { + m.archiveEmails[i].Body = body + m.archiveEmails[i].Attachments = attachments + break + } + } + if emails, ok := m.archiveByAcct[accountID]; ok { + for i := range emails { + if emails[i].UID == uid { + emails[i].Body = body + emails[i].Attachments = attachments + break + } + } + } default: for i := range m.emails { if m.emails[i].UID == uid && m.emails[i].AccountID == accountID { @@ -829,6 +985,40 @@ func (m *mainModel) removeEmailByMailbox(uid uint32, accountID string, mailbox t } m.sentByAcct[accountID] = filteredAcct } + case tui.MailboxTrash: + var filtered []fetcher.Email + for _, e := range m.trashEmails { + if !(e.UID == uid && e.AccountID == accountID) { + filtered = append(filtered, e) + } + } + m.trashEmails = filtered + if emails, ok := m.trashByAcct[accountID]; ok { + var filteredAcct []fetcher.Email + for _, e := range emails { + if e.UID != uid { + filteredAcct = append(filteredAcct, e) + } + } + m.trashByAcct[accountID] = filteredAcct + } + case tui.MailboxArchive: + var filtered []fetcher.Email + for _, e := range m.archiveEmails { + if !(e.UID == uid && e.AccountID == accountID) { + filtered = append(filtered, e) + } + } + m.archiveEmails = filtered + if emails, ok := m.archiveByAcct[accountID]; ok { + var filteredAcct []fetcher.Email + for _, e := range emails { + if e.UID != uid { + filteredAcct = append(filteredAcct, e) + } + } + m.archiveByAcct[accountID] = filteredAcct + } default: var filtered []fetcher.Email for _, e := range m.emails { @@ -880,9 +1070,14 @@ func fetchAllAccountsEmails(cfg *config.Config, mailbox tui.MailboxKind) tea.Cmd defer wg.Done() var emails []fetcher.Email var err error - if mailbox == tui.MailboxSent { + switch mailbox { + case tui.MailboxSent: emails, err = fetcher.FetchSentEmails(&acc, initialEmailLimit, 0) - } else { + case tui.MailboxTrash: + emails, err = fetcher.FetchTrashEmails(&acc, initialEmailLimit, 0) + case tui.MailboxArchive: + emails, err = fetcher.FetchArchiveEmails(&acc, initialEmailLimit, 0) + default: emails, err = fetcher.FetchEmails(&acc, initialEmailLimit, 0) } if err != nil { @@ -919,6 +1114,30 @@ func fetchEmails(account *config.Account, limit, offset uint32, mailbox tui.Mail } } +func fetchEmailsForMailbox(account *config.Account, limit, offset uint32, mailbox tui.MailboxKind) tea.Cmd { + return func() tea.Msg { + var emails []fetcher.Email + var err error + switch mailbox { + case tui.MailboxSent: + emails, err = fetcher.FetchSentEmails(account, limit, offset) + case tui.MailboxTrash: + emails, err = fetcher.FetchTrashEmails(account, limit, offset) + case tui.MailboxArchive: + emails, err = fetcher.FetchArchiveEmails(account, limit, offset) + default: + emails, err = fetcher.FetchEmails(account, limit, offset) + } + if err != nil { + return tui.FetchErr(err) + } + if offset == 0 { + return tui.EmailsFetchedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox} + } + return tui.EmailsAppendedMsg{Emails: emails, AccountID: account.ID, Mailbox: mailbox} + } +} + func loadCachedEmails() tea.Cmd { return func() tea.Msg { cache, err := config.LoadEmailCache() @@ -1017,9 +1236,14 @@ func fetchEmailBodyCmd(cfg *config.Config, email fetcher.Email, uid uint32, acco attachments []fetcher.Attachment err error ) - if mailbox == tui.MailboxSent { + switch mailbox { + case tui.MailboxSent: body, attachments, err = fetcher.FetchSentEmailBody(account, uid) - } else { + case tui.MailboxTrash: + body, attachments, err = fetcher.FetchTrashEmailBody(account, uid) + case tui.MailboxArchive: + body, attachments, err = fetcher.FetchArchiveEmailBody(account, uid) + default: body, attachments, err = fetcher.FetchEmailBody(account, uid) } if err != nil { @@ -1103,9 +1327,14 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { func deleteEmailCmd(account *config.Account, uid uint32, accountID string, mailbox tui.MailboxKind) tea.Cmd { return func() tea.Msg { var err error - if mailbox == tui.MailboxSent { + switch mailbox { + case tui.MailboxSent: err = fetcher.DeleteSentEmail(account, uid) - } else { + case tui.MailboxTrash: + err = fetcher.DeleteTrashEmail(account, uid) + case tui.MailboxArchive: + err = fetcher.DeleteArchiveEmail(account, uid) + default: err = fetcher.DeleteEmail(account, uid) } return tui.EmailActionDoneMsg{UID: uid, AccountID: accountID, Mailbox: mailbox, Err: err} @@ -1129,9 +1358,14 @@ func downloadAttachmentCmd(account *config.Account, uid uint32, msg tui.Download // Download and decode the attachment using encoding provided in msg.Encoding. var data []byte var err error - if msg.Mailbox == tui.MailboxSent { + switch msg.Mailbox { + case tui.MailboxSent: data, err = fetcher.FetchSentAttachment(account, uid, msg.PartID, msg.Encoding) - } else { + case tui.MailboxTrash: + data, err = fetcher.FetchTrashAttachment(account, uid, msg.PartID, msg.Encoding) + case tui.MailboxArchive: + data, err = fetcher.FetchArchiveAttachment(account, uid, msg.PartID, msg.Encoding) + default: data, err = fetcher.FetchAttachment(account, uid, msg.PartID, msg.Encoding) } if err != nil { diff --git a/public/assets/demo.gif b/public/assets/demo.gif index 5ac8b5587446ad9c9f747bef08fdfae5213eaa5f..28138e399dcf66fb6a7a1a012bb01f866ec76f12 100644 Binary files a/public/assets/demo.gif and b/public/assets/demo.gif differ diff --git a/public/index.html b/public/index.html index 4c10658714f3c327fd21fae2546a154326b20451..a4e57f279f476c62d1d372c5c1625399f9e6e2e6 100644 --- a/public/index.html +++ b/public/index.html @@ -4,6 +4,10 @@
-