From e68567c0105b9d664ad35003f4412760b27c7d6e Mon Sep 17 00:00:00 2001 From: tyler Date: Wed, 20 Mar 2024 12:36:45 -0400 Subject: [PATCH] Implemented functionality to add accounts and channels --- v1/app.go | 128 ++++- v1/frontend/src/App.jsx | 4 +- v1/frontend/src/Navigation.jsx | 1 + .../src/assets/icons/chevron-right.png | Bin 0 -> 2295 bytes .../assets/icons/circle-green-background.png | Bin 0 -> 6709 bytes .../assets/icons/circle-red-background.png | Bin 0 -> 6627 bytes v1/frontend/src/assets/icons/heart-fill.png | Bin 0 -> 4885 bytes .../src/assets/icons/plus-circle-fill.png | Bin 0 -> 4887 bytes v1/frontend/src/assets/index.js | 8 + v1/frontend/src/components/ChannelSideBar.css | 357 +++++++++++++ v1/frontend/src/components/ChannelSideBar.jsx | 503 ++++++++++++++++++ v1/frontend/src/components/Modal.css | 17 +- v1/frontend/src/components/Modal.jsx | 9 +- v1/frontend/src/screens/Dashboard.css | 7 + v1/frontend/src/screens/Dashboard.jsx | 14 + v1/frontend/src/screens/SignIn.jsx | 18 +- v1/frontend/src/screens/Start.css | 0 v1/frontend/src/screens/Start.jsx | 0 v1/internal/config/config.go | 18 + v1/internal/models/account.go | 100 +++- v1/internal/models/accountchannel.go | 94 ++++ v1/internal/models/channel.go | 232 ++++++++ v1/internal/models/error.go | 5 + v1/internal/models/services.go | 49 +- v1/main.go | 15 +- 25 files changed, 1536 insertions(+), 43 deletions(-) create mode 100644 v1/frontend/src/assets/icons/chevron-right.png create mode 100644 v1/frontend/src/assets/icons/circle-green-background.png create mode 100644 v1/frontend/src/assets/icons/circle-red-background.png create mode 100644 v1/frontend/src/assets/icons/heart-fill.png create mode 100644 v1/frontend/src/assets/icons/plus-circle-fill.png create mode 100644 v1/frontend/src/components/ChannelSideBar.css create mode 100644 v1/frontend/src/components/ChannelSideBar.jsx create mode 100644 v1/frontend/src/screens/Dashboard.css create mode 100644 v1/frontend/src/screens/Dashboard.jsx create mode 100644 v1/frontend/src/screens/Start.css create mode 100644 v1/frontend/src/screens/Start.jsx create mode 100644 v1/internal/models/accountchannel.go create mode 100644 v1/internal/models/channel.go diff --git a/v1/app.go b/v1/app.go index 3d72dc3..d01b6c6 100644 --- a/v1/app.go +++ b/v1/app.go @@ -67,6 +67,8 @@ func (a *App) startup(ctx context.Context) { services, err := models.NewServices( models.WithDatabase(db), models.WithAccountService(), + models.WithChannelService(), + models.WithAccountChannelService(), ) if err != nil { log.Fatal(err) @@ -100,6 +102,84 @@ func (a *App) shutdown(ctx context.Context) { a.logFileMu.Unlock() } +func (a *App) AddChannel(apiKey string) error { + client := rumblelivestreamlib.Client{StreamKey: apiKey} + resp, err := client.Request() + if err != nil { + a.logError.Println("error executing api request:", err) + return fmt.Errorf("Error querying API. Verify key and try again.") + } + + userKey := apiKey + channelKey := "" + if resp.Type == "channel" { + userKey = "" + channelKey = apiKey + } + + err = a.addAccountNotExist(resp.UserID, resp.Username, userKey) + if err != nil { + a.logError.Println("error adding account if not exist:", err) + return fmt.Errorf("Error adding channel. Try again.") + } + + if resp.Type == "channel" { + err = a.addChannelNotExist(resp.Username, fmt.Sprint(resp.ChannelID), resp.ChannelName, channelKey) + if err != nil { + a.logError.Println("error adding channel if not exist:", err) + return fmt.Errorf("Error adding channel. Try again.") + } + } + + return nil +} + +func (a *App) addAccountNotExist(uid string, username string, apiKey string) error { + acct, err := a.services.AccountS.ByUsername(username) + if err != nil { + return fmt.Errorf("error querying account by username: %v", err) + } + if acct == nil { + err = a.services.AccountS.Create(&models.Account{ + UID: &uid, + Username: &username, + ApiKey: &apiKey, + }) + if err != nil { + return fmt.Errorf("error creating account: %v", err) + } + } + return nil +} + +func (a *App) addChannelNotExist(username string, cid string, name string, apiKey string) error { + channel, err := a.services.ChannelS.ByName(name) + if err != nil { + return fmt.Errorf("error querying channel by name: %v", err) + } + if channel == nil { + acct, err := a.services.AccountS.ByUsername(username) + if err != nil { + return fmt.Errorf("error querying account by username: %v", err) + } + if acct == nil { + return fmt.Errorf("account does not exist with username: %s", username) + } + + err = a.services.ChannelS.Create(&models.Channel{ + AccountID: acct.ID, + CID: &cid, + Name: &name, + ApiKey: &apiKey, + }) + if err != nil { + return fmt.Errorf("error creating channel: %v", err) + } + } + + return nil +} + func (a *App) Login(username string, password string) error { var err error client, exists := a.clients[username] @@ -133,7 +213,7 @@ func (a *App) Login(username string, password string) error { return fmt.Errorf("Error logging in. Try again.") } if act == nil { - act = &models.Account{nil, &username, &cookiesS} + act = &models.Account{nil, nil, &username, &cookiesS, nil, nil} err = a.services.AccountS.Create(act) if err != nil { a.logError.Println("error creating account:", err) @@ -143,10 +223,54 @@ func (a *App) Login(username string, password string) error { act.Cookies = &cookiesS err = a.services.AccountS.Update(act) if err != nil { - a.logError.Println("error updating account", err) + a.logError.Println("error updating account:", err) return fmt.Errorf("Error logging in. Try again.") } } return nil } + +func (a *App) SignedIn() (bool, error) { + accounts, err := a.services.AccountS.All() + if err != nil { + a.logError.Println("error getting all accounts:", err) + return false, fmt.Errorf("Error retrieving accounts. Try restarting.") + } + + return len(accounts) > 0, nil +} + +type Account struct { + Account models.Account `json:"account"` + Channels []models.Channel `json:"channels"` +} + +func (a *App) AccountList() (map[string]*Account, error) { + list := map[string]*Account{} + + accountChannels, err := a.services.AccountChannelS.All() + if err != nil { + a.logError.Println("error getting all account channels:", err) + return nil, fmt.Errorf("Error retrieving accounts and channels. Try restarting.") + } + + for _, ac := range accountChannels { + if ac.Account.Username == nil { + a.logError.Println("account-channel contains nil account username") + return nil, fmt.Errorf("Error retrieving accounts and channels. Try restarting.") + } + + act, exists := list[*ac.Account.Username] + if !exists || act == nil { + act = &Account{ac.Account, []models.Channel{}} + list[*ac.Account.Username] = act + } + + if ac.Channel.AccountID != nil { + act.Channels = append(act.Channels, ac.Channel) + } + } + + return list, nil +} diff --git a/v1/frontend/src/App.jsx b/v1/frontend/src/App.jsx index de85ca2..39affbd 100644 --- a/v1/frontend/src/App.jsx +++ b/v1/frontend/src/App.jsx @@ -1,7 +1,8 @@ import { useState } from 'react'; import { MemoryRouter as Router, Route, Routes, Link } from 'react-router-dom'; import './App.css'; -import { NavSignIn } from './Navigation'; +import { NavDashboard, NavSignIn } from './Navigation'; +import Dashboard from './screens/Dashboard'; import SignIn from './screens/SignIn'; function App() { @@ -9,6 +10,7 @@ function App() { } /> + } /> ); diff --git a/v1/frontend/src/Navigation.jsx b/v1/frontend/src/Navigation.jsx index 3b66294..2293540 100644 --- a/v1/frontend/src/Navigation.jsx +++ b/v1/frontend/src/Navigation.jsx @@ -1 +1,2 @@ +export const NavDashboard = '/dashboard'; export const NavSignIn = '/'; diff --git a/v1/frontend/src/assets/icons/chevron-right.png b/v1/frontend/src/assets/icons/chevron-right.png new file mode 100644 index 0000000000000000000000000000000000000000..cdcd507957de1f08f98d5799d9d3f56ade019714 GIT binary patch literal 2295 zcma)-c{~&TAIG;D+d^iRqb8gCiaA1#nQiW)xlPS2N6~U7Q!7V|ut-#0A*G*Ye)_3i|R8qN^O1IZ5EOL>^fhAV?==T zNxhJV?8X)9p_Y)c@ZR6q`P{Cw%+n;7HYsx3?v#79 z?h(%#Sed}IFDJ%$+Hrb|8KgFeT){>@FaLU()*%N$w!2&~{HWAB#+X7H4fGfUS6ANt z-Mip@X13@Gt%O#Qtyy3?=cpg@gsH=EY6*zpUiY~oX&7Ro&g%2WhdOHCUrEnP$Wc%4 z%k3Ca%`{+t>iM7W)#BqS^_am$~)IsdEHgxn$QeWHCYZvL0I zCHWg*u9ta<5^*q2BH@0y`43F%{z{1hN80#;4!w#ydnnT}FEk*uK&Z6{Z@ce(ZP__o z|6))v*T|}7K#bb8a`^k;?$>Vp_20qZ(=(*Yya#~MtSFHHK*8Xj0CHIsvH$=?)0$-F za^7pXm>MBw31`GokAVj76!jUrJTxtzZ6pc3yoFZOGZ>IgJ$`*lc*fY`0tuI>r{g(r zDY$`SV)|x@UR`d3oG@uAX%Ai2le<@a9uEM)82~UE0EGUVr4gw#r11UjSQ&AKjDiLH zDyK~2?1PSP7A`hc7xpc0kKFv$YExNbG$JmegF01qJ7;Nvnh-w!(G+bM=XCP^MAuVaY_!+H$v+1Yr-wdCMi9c)~ z8FjP`r$3?npU5Snwz8f07)^)x`bW*P#O`tP7Y=wWnqiqFWiV+8d}cNLi>;`267*>+R^r)i{3>tA)O!2!-q9`0jywAGpmFJ(Yp&P@!Ji+i0 zc-xV_rd2K~C&DSIX{7X|T4cOX^34WKwo1#DWgW7PfT*DnifeuG zEHlB>q{)jUP3(Cf3)@We5=8Q9TsVd}+9V0Qi5Qt(*(z$`Rym|GB^1yI=C~E&WD&zE zzejD(oQZ_Pihrjb_ISWM-Ncbfex^7tfl52*(#lJ`t(}}nd@sz2eUKlq3cY-yI=x6?ooumo zo#V@`~;Kow8c9_ymkhYNy_K&7Aos`JXrW)R1ks%0R^!Ugr zOc_cMG*}S!0#A@4gLBs+m=**jyL2=-DoBAzBIuul&Of(45U8*dt8H}DztDXKlsqmi zSNcwf0z!n(CNs24TwYtfyCw{DwHe6CrkUt~b6YB2;=46pV@f@hsIrL3xfF(BRx3OE ztGG;yZ>!52g%4ESsg*3RQTu4c8f=}2;cH;8K$5LTRQKMg4!!7&`o=~4Y!X!Y;^$|I zxf&Qy%7(S;!vI*5fQyQlUdTE7t)cmAKMG%+Nik_aTs5iIeakkU*A0GCJHvs^MQ?C7 ze|hf}>{k7DtKj>&CRn)4f${#8!F-pzY6>F_-=a}}y)Lxcd9+BcCa#G40Sg-R3M}Un z+ft>O48|cmsF1>k-Q8n82_Y?&bL#_~1;zj?R&zd1EP_p}oS`d2=QC8pOS9-d64$PB zUz?B1WjGgW*`aF6t8weD1$#G9Af^jU<|Lb*rwB@k9c=PjSjUvst4{N=Y6EQ`LaT0P z&GjlKWeJ^L)NTRvRKjhjxt zKc{7VOyyj`-ovvg!oUfGwDRm>X$eqseFT$)Q?k=HgDP|18qIO%4hG;w$3|Pb6YMs?(Sf#CW1E*o8*I@juQ)f{|;;{=V_fhzNirEtQU^jEibf$cwiB5?%0zEA-xaDBk&mvE9xcqq$H!nI+` zbkfD(+XS^w?zL4VA=vfhhS-*I+E$2|Moa$w#@ykhP_uU0&niS%ScbVZtYjV4q z_RYV9ZjV#KFxu|Gy%@h_xDBif^Z~}EJl0c2 zH|P@RNTCTZulPI8qx#O{oH6G}%boP?FFBu8b_1KaC)BD~wbRL0|Gh{3-_MYQkC*sw W4Vz%2rki005u}1^@s6i_d2*0004QX+uL$X=7sm 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 literal 0 HcmV?d00001 diff --git a/v1/frontend/src/assets/icons/circle-red-background.png b/v1/frontend/src/assets/icons/circle-red-background.png new file mode 100644 index 0000000000000000000000000000000000000000..5968d0d4e9928913d70417f839cab32f89f02219 GIT binary patch literal 6627 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 literal 0 HcmV?d00001 diff --git a/v1/frontend/src/assets/icons/heart-fill.png b/v1/frontend/src/assets/icons/heart-fill.png new file mode 100644 index 0000000000000000000000000000000000000000..293d511322c474af89d9dc632dfa952c0a259137 GIT binary patch literal 4885 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 Hu0mjf&-xx( literal 0 HcmV?d00001 diff --git a/v1/frontend/src/assets/icons/plus-circle-fill.png b/v1/frontend/src/assets/icons/plus-circle-fill.png new file mode 100644 index 0000000000000000000000000000000000000000..785e701afcbcd2e54497d3a424f1d56d4b888ea7 GIT binary patch literal 4887 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 JPDHLkV1jPW>!|<$ literal 0 HcmV?d00001 diff --git a/v1/frontend/src/assets/index.js b/v1/frontend/src/assets/index.js index 6099f55..8606948 100644 --- a/v1/frontend/src/assets/index.js +++ b/v1/frontend/src/assets/index.js @@ -1,9 +1,17 @@ +import chevron_right from './icons/chevron-right.png'; +import circle_green_background from './icons/circle-green-background.png'; import eye from './icons/eye.png'; import eye_slash from './icons/eye-slash.png'; +import heart from './icons/heart-fill.png'; +import plus_circle from './icons/plus-circle-fill.png' import x_lg from './icons/x-lg.png'; import logo from './logo/logo.png'; +export const ChevronRight = chevron_right; +export const CircleGreenBackground = circle_green_background; export const Eye = eye; export const EyeSlash = eye_slash; +export const Heart = heart; export const Logo = logo; +export const PlusCircle = plus_circle; export const XLg = x_lg; \ No newline at end of file diff --git a/v1/frontend/src/components/ChannelSideBar.css b/v1/frontend/src/components/ChannelSideBar.css new file mode 100644 index 0000000..2fba033 --- /dev/null +++ b/v1/frontend/src/components/ChannelSideBar.css @@ -0,0 +1,357 @@ + +.channel-sidebar { + align-items: center; + background-color: #061726; + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + padding: 0px 10px; +} + +.channel-sidebar-account-list { + border-top: 2px solid #273848; + padding-bottom: 10px; +} + +.channel-sidebar-body { + overflow-y: auto; +} + +.channel-sidebar-button { + align-items: center; + background-color: #061726; + border: none; + display: flex; + justify-content: center; + padding: 0px; +} + +.channel-sidebar-button:hover { + cursor: pointer; +} + +.channel-sidebar-button-icon { + height: 60px; + width: 60px; +} + +.channel-sidebar-footer { + padding-bottom: 10px; +} + +.channel-sidebar-icon { + height: 60px; + margin-top: 10px; + position: relative; + width: 60px; +} + +.channel-sidebar-icon-account { + bottom: 0px; + height: 24px; + left: 36px; + position: absolute; + width: 24px; +} + +.channel-sidebar-icon-hover { + background-color: #061726; + border-radius: 5px; + color: black; + padding: 10px; + position: fixed; + /* transform: translate(75px, -50px); */ + z-index: 10; +} +.channel-sidebar-icon-hover:before { + content:""; + position: absolute; + width: 0; + height: 0; + border-top: 3px solid transparent; + border-right: 3px solid #061726; + border-bottom: 3px solid transparent; + margin: 7px 0 0 -13px; +} + +.channel-sidebar-icon-hover-text { + color: white; + font-family: sans-serif; + font-weight: bold; + font-size: 16px; +} + +.channel-sidebar-icon-image { + /* border: 3px solid #85c742; */ + /* border: 3px solid #ec0; */ + border: 3px solid #f23160; + border-radius: 50%; + height: 54px; + transition: border-radius 0.25s; + width: 54px; +} + +.channel-sidebar-icon-image:hover { + border-radius: 30%; + transition: border-radius 0.25s; +} + +.channel-sidebar-icon-initial { + align-items: center; + background-color: #3377cc; + /* border: 3px solid #85c742; */ + /* border: 3px solid #ec0; */ + border: 3px solid #f23160; + border-radius: 50%; + color: #eee; + display: flex; + font-family: sans-serif; + font-size: 34px; + font-weight: bold; + height: 54px; + justify-content: center; + transition: border-radius 0.25s; + width: 54px; +} + +.channel-sidebar-icon-initial:hover { + border-radius: 30%; + transition: border-radius 0.25s; +} + +.modal-add-account-channel { + align-items: center; + display: flex; + flex-direction: column; + height: 100%; + justify-content: space-between; + width: 100%; +} + +.modal-add-account-channel-header { + align-items: center; + display: flex; + flex-direction: column; +} + +.modal-add-account-channel-subtitle { + color: white; + font-family: sans-serif; + font-size: 14px; + margin-top: 10px; + text-align: center; +} + +.modal-add-account-channel-title { + color: white; + font-family: sans-serif; + font-size: 24px; + font-weight: bold; + text-align: center; +} + +.modal-add-account-channel-body { + align-items: center; + display: flex; + flex-direction: column; + width: 100%; +} + +.modal-add-account-channel-button { + align-items: center; + background-color: #1f2e3c; + border: 1px solid #d6e0ea; + border-radius: 5px; + display: flex; + flex-direction: row; + justify-content: space-between; + margin: 5px 0px; + padding: 20px; + width: 100%; +} + +.modal-add-account-channel-button:hover { + background-color: rgba(255, 255, 255, 0.1); + cursor: pointer; +} + +.modal-add-account-channel-button-left { + color: white; + font-family: sans-serif; + font-size: 16px; + font-weight: bold; +} + +.modal-add-account-channel-button-right-icon { + height: 20px; + width: 20px; +} + +.modal-add-account-channel-input { + display: flex; + flex-direction: row; + justify-content: space-between; + margin-bottom: 10px; + width: 100%; +} + +.modal-add-account-channel-input-password { + background-color: #061726; + border: none; + border-radius: 5px 0px 0px 5px; + box-sizing: border-box; + color: white; + font-family: monospace; + font-size: 16px; + outline: none; + padding: 10px; + resize: none; + width: 90%; +} + +.modal-add-account-channel-input-text { + background-color: #061726; + border: none; + border-radius: 5px; + box-sizing: border-box; + color: white; + font-family: monospace; + font-size: 16px; + outline: none; + padding: 10px; + resize: none; + width: 100%; +} + +.modal-add-account-channel-input-show { + align-items: center; + background-color: #061726; + border: none; + border-radius: 0px 5px 5px 0px; + display: flex; + justify-content: center; + width: 10%; +} + +.modal-add-account-channel-input-show:hover { + cursor: pointer; +} + +.modal-add-account-channel-input-show-icon { + height: 16px; + width: 16px; +} + +.modal-add-account-channel-label { + color: white; + font-family: sans-serif; + font-size: 14px; + font-weight: bold; + margin-bottom: 10px; + width: 100%; +} + +.modal-add-account-channel-label-warning { + color: #f23160; + font-family: sans-serif; + font-size: 14px; + font-weight: bold; + margin-bottom: 10px; + width: 100%; +} + +.modal-add-channel-description { + color: white; + font-family: sans-serif; + font-size: 16px; + font-weight: bold; + margin-top: 20px; + text-align: left; + width: 100%; +} + +.modal-add-channel-description-subtext { + color: white; + font-family: sans-serif; + font-size: 16px; + margin-top: 10px; + text-align: left; + width: 100%; +} + +.modal-add-channel-key { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; +} + +.modal-add-channel-key-input { + background-color: #061726; + border: none; + border-radius: 5px 0px 0px 5px; + box-sizing: border-box; + color: white; + font-family: monospace; + font-size: 16px; + outline: none; + padding: 10px; + resize: none; + width: 90%; +} + +.modal-add-channel-key-show { + align-items: center; + background-color: #061726; + border: none; + border-radius: 0px 5px 5px 0px; + display: flex; + justify-content: center; + width: 10%; +} + +.modal-add-channel-key-show:hover { + cursor: pointer; +} + +.modal-add-channel-key-show-icon { + height: 16px; + width: 16px; +} + +.modal-add-channel-label { + color: white; + font-family: sans-serif; + font-size: 14px; + font-weight: bold; + margin-bottom: 10px; + width: 100%; +} + +.modal-add-channel-label-warning { + color: #f23160; + font-family: sans-serif; + font-size: 14px; + font-weight: bold; + margin-bottom: 10px; + width: 100%; +} + +/* HTML:
*/ +.loader { + width: 60px; + aspect-ratio: 6; + --_g: no-repeat radial-gradient(circle closest-side,#061726 90%,#0000); + background: + var(--_g) 0% 50%, + var(--_g) 50% 50%, + var(--_g) 100% 50%; + background-size: calc(100%/3) 100%; + animation: l7 1s infinite linear; +} +@keyframes l7 { + 33%{background-size:calc(100%/3) 0% ,calc(100%/3) 100%,calc(100%/3) 100%} + 50%{background-size:calc(100%/3) 100%,calc(100%/3) 0% ,calc(100%/3) 100%} + 66%{background-size:calc(100%/3) 100%,calc(100%/3) 100%,calc(100%/3) 0% } +} \ No newline at end of file diff --git a/v1/frontend/src/components/ChannelSideBar.jsx b/v1/frontend/src/components/ChannelSideBar.jsx new file mode 100644 index 0000000..0619fcb --- /dev/null +++ b/v1/frontend/src/components/ChannelSideBar.jsx @@ -0,0 +1,503 @@ +import { useEffect, useState } from 'react'; +import { Modal, SmallModal } from './Modal'; +import { AccountList, AddChannel, Login } from '../../wailsjs/go/main/App'; + +import { ChevronRight, CircleGreenBackground, Eye, EyeSlash, PlusCircle } from '../assets'; +import './ChannelSideBar.css'; + +function ChannelSideBar(props) { + const [accounts, setAccounts] = useState({}); + const [error, setError] = useState(''); + const [addOpen, setAddOpen] = useState(false); + const [refresh, setRefresh] = useState(false); + const [scrollY, setScrollY] = useState(0); + + useEffect(() => { + AccountList() + .then((response) => { + setAccounts(response); + }) + .catch((error) => { + setError(error); + }); + }, [refresh]); + + const sortAccounts = () => { + let keys = Object.keys(accounts); + + let sorted = [...keys].sort((a, b) => + accounts[a].account.username.toLowerCase() > accounts[b].account.username.toLowerCase() + ? 1 + : -1 + ); + + return sorted; + }; + + const handleScroll = (event) => { + setScrollY(event.target.scrollTop); + }; + + return ( + <> + setAddOpen(false)} + onRefresh={() => { + setRefresh(!refresh); + }} + show={addOpen} + /> +
+
+ {sortAccounts().map((account, index) => ( + + ))} +
+
+ setAddOpen(true)} + scrollY={0} + /> +
+
+ + ); +} + +export default ChannelSideBar; + +function AccountChannels(props) { + const sortChannels = () => { + let sorted = [...props.account.channels].sort((a, b) => + a.name.toLowerCase() > b.name.toLowerCase() ? 1 : -1 + ); + + return sorted; + }; + + if (props.account.account !== undefined) { + return ( +
+ + {sortChannels().map((channel, index) => ( + + ))} +
+ ); + } +} + +function AccountIcon(props) { + const [hover, setHover] = useState(false); + + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + > + {props.account.profile_image === null ? ( + + {props.account.username[0].toUpperCase()} + + ) : ( + + )} + + {hover && ( + + )} +
+ ); +} + +function ButtonIcon(props) { + const [hover, setHover] = useState(false); + + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + > + + {hover && } +
+ ); +} + +function ChannelIcon(props) { + const [hover, setHover] = useState(false); + return ( +
setHover(true)} + onMouseLeave={() => setHover(false)} + > + {props.channel.profile_image === null ? ( + + {props.channel.name[0].toUpperCase()} + + ) : ( + + )} + {hover && ( + + )} +
+ ); +} + +function HoverName(props) { + return ( +
+ {props.name} +
+ ); +} + +function ModalAdd(props) { + const [accountPassword, setAccountPassword] = useState(''); + const [accountPasswordValid, setAccountPasswordValid] = useState(true); + const updateAccountPassword = (event) => { + if (loading()) { + return; + } + setAccountPassword(event.target.value); + }; + const [accountUsername, setAccountUsername] = useState(''); + const [accountUsernameValid, setAccountUsernameValid] = useState(true); + const updateAccountUsername = (event) => { + if (loading()) { + return; + } + setAccountUsername(event.target.value); + }; + const [addAccountLoading, setAddAccountLoading] = useState(false); + const [addChannelLoading, setAddChannelLoading] = useState(false); + const [channelKey, setChannelKey] = useState(''); + const [channelKeyValid, setChannelKeyValid] = useState(true); + const updateChannelKey = (event) => { + if (loading()) { + return; + } + setChannelKey(event.target.value); + }; + const [error, setError] = useState(''); + const [stage, setStage] = useState('start'); + + useEffect(() => { + if (addAccountLoading) { + Login(accountUsername, accountPassword) + .then(() => { + reset(); + props.onClose(); + props.onRefresh(); + }) + .catch((error) => { + setAddAccountLoading(false); + setError(error); + }); + } + }, [addAccountLoading]); + + useEffect(() => { + if (addChannelLoading) { + AddChannel(channelKey) + .then(() => { + reset(); + props.onClose(); + props.onRefresh(); + }) + .catch((error) => { + setAddChannelLoading(false); + setError(error); + }); + } + }, [addChannelLoading]); + + const back = () => { + if (loading()) { + return; + } + reset(); + }; + + const close = () => { + if (loading()) { + return; + } + reset(); + props.onClose(); + }; + + const reset = () => { + setStage('start'); + resetAccount(); + resetChannel(); + }; + + const add = () => { + switch (stage) { + case 'account': + addAccount(); + break; + case 'channel': + addChannel(); + break; + default: + close(); + } + }; + + const addAccount = () => { + if (loading()) { + return; + } + + if (accountUsername === '') { + setAccountUsernameValid(false); + return; + } + + if (accountPassword === '') { + setAccountPasswordValid(false); + return; + } + + setAddAccountLoading(true); + }; + + const addChannel = () => { + if (loading()) { + return; + } + + if (channelKey === '') { + setChannelKeyValid(false); + return; + } + + setAddChannelLoading(true); + }; + + const loading = () => { + return addAccountLoading || addChannelLoading; + }; + + const resetAccount = () => { + setAccountPassword(''); + setAccountPasswordValid(true); + setAccountUsername(''); + setAccountUsernameValid(true); + setAddAccountLoading(false); + }; + + const resetChannel = () => { + setChannelKey(''); + setChannelKeyValid(true); + setAddChannelLoading(false); + }; + + return ( + <> + setError('')} + show={error !== ''} + style={{ minWidth: '300px', maxWidth: '200px', maxHeight: '200px' }} + title={'Error'} + message={error} + submitButton={'OK'} + onSubmit={() => setError('')} + /> + + {stage === 'start' && } + {stage === 'account' && ( + + )} + {stage === 'channel' && ( + + )} + + + ); +} + +function ModalAddAccount(props) { + const [showKey, setShowKey] = useState(false); + const updateShowKey = () => setShowKey(!showKey); + const [showPassword, setShowPassword] = useState(false); + const updateShowPassword = () => setShowPassword(!showPassword); + + return ( +
+
+ Add Account + + Log into your Rumble account + +
+
+ {props.accountUsernameValid === false ? ( + + ) : ( + + )} +
+ +
+ {props.accountPasswordValid === false ? ( + + ) : ( + + )} +
+ + +
+
+
+
+ ); +} + +function ModalAddChannel(props) { + const [showKey, setShowKey] = useState(false); + const updateShowKey = () => setShowKey(!showKey); + + return ( +
+
+ Add Channel + + Copy an API key below to add a channel + +
+
+ {props.channelKeyValid === false ? ( + + ) : ( + + )} +
+ + +
+ API KEYS SHOULD LOOK LIKE + + https://rumble.com/-livestream-api/get-data?key=really-long_string-of_random-characters + +
+
+
+ ); +} + +function ModalAddStart(props) { + return ( +
+ Add an Account or Channel +
+ + +
+
+
+ ); +} diff --git a/v1/frontend/src/components/Modal.css b/v1/frontend/src/components/Modal.css index d4da5b4..670161f 100644 --- a/v1/frontend/src/components/Modal.css +++ b/v1/frontend/src/components/Modal.css @@ -1,7 +1,8 @@ .modal-background { align-items: center; - background-color: transparent; + /* background-color: transparent; */ + background-color: rgba(0,0,0,0.8); display: flex; height: 100vh; justify-content: center; @@ -29,6 +30,7 @@ font-weight: bold; text-decoration: none; /* width: 20%; */ + height: 40px; width: 70px; } @@ -36,12 +38,14 @@ background-color: transparent; border: 1px solid #495a6a; border-radius: 5px; - color: #495a6a; + /* color: #495a6a; */ + color: white; cursor: pointer; font-size: 18px; font-weight: bold; text-decoration: none; /* width: 20%; */ + height: 40px; width: 70px; } @@ -55,6 +59,7 @@ font-weight: bold; text-decoration: none; /* width: 20%; */ + height: 40px; width: 70px; } @@ -79,8 +84,8 @@ .modal-container { align-items: center; - background-color: rgba(6,23,38,1); - border: 1px solid #495a6a; + background-color: #1f2e3c; + /* border: 1px solid #495a6a; */ border-radius: 15px; color: black; display: flex; @@ -133,7 +138,7 @@ align-items: center; /* background-color: rgba(6,23,38,1); */ background-color: white; - border: 1px solid #495a6a; + /* border: 1px solid #495a6a; */ /* border: 1px solid black; */ border-radius: 15px; color: black; @@ -173,4 +178,4 @@ color: black; font-family: sans-serif; font-size: 24px; -} \ No newline at end of file +} diff --git a/v1/frontend/src/components/Modal.jsx b/v1/frontend/src/components/Modal.jsx index f123753..69aa0ad 100644 --- a/v1/frontend/src/components/Modal.jsx +++ b/v1/frontend/src/components/Modal.jsx @@ -6,7 +6,7 @@ export function Modal(props) {
- {props.submitButton} + {/* {props.submitButton} */} + {props.submitLoading ? ( +
+ ) : ( + props.submitButton + )} )}
diff --git a/v1/frontend/src/screens/Dashboard.css b/v1/frontend/src/screens/Dashboard.css new file mode 100644 index 0000000..195aeb3 --- /dev/null +++ b/v1/frontend/src/screens/Dashboard.css @@ -0,0 +1,7 @@ +.dashboard { + align-items: center; + display: flex; + flex-direction: row; + height: 100vh; + width: 100%; +} diff --git a/v1/frontend/src/screens/Dashboard.jsx b/v1/frontend/src/screens/Dashboard.jsx new file mode 100644 index 0000000..40ec5dc --- /dev/null +++ b/v1/frontend/src/screens/Dashboard.jsx @@ -0,0 +1,14 @@ +import { CircleGreenBackground, Heart } from '../assets'; +import ChannelSideBar from '../components/ChannelSideBar'; +import './Dashboard.css'; + +function Dashboard() { + return ( +
+ +
+
+ ); +} + +export default Dashboard; diff --git a/v1/frontend/src/screens/SignIn.jsx b/v1/frontend/src/screens/SignIn.jsx index 1ef9e73..2f04f9f 100644 --- a/v1/frontend/src/screens/SignIn.jsx +++ b/v1/frontend/src/screens/SignIn.jsx @@ -1,11 +1,14 @@ import { useEffect, useState } from 'react'; import { SmallModal } from '../components/Modal'; -import { Login } from '../../wailsjs/go/main/App'; +import { Login, SignedIn } from '../../wailsjs/go/main/App'; import { Eye, EyeSlash, Logo } from '../assets'; +import { Navigate, useNavigate } from 'react-router-dom'; import './SignIn.css'; +import { NavDashboard } from '../Navigation'; function SignIn() { const [error, setError] = useState(''); + const navigate = useNavigate(); const [password, setPassword] = useState(''); const updatePassword = (event) => setPassword(event.target.value); const [showPassword, setShowPassword] = useState(false); @@ -14,12 +17,25 @@ function SignIn() { const [username, setUsername] = useState(''); const updateUsername = (event) => setUsername(event.target.value); + useEffect(() => { + SignedIn() + .then((signedIn) => { + if (signedIn) { + navigate(NavDashboard); + } + }) + .catch((error) => { + setError(error); + }); + }, []); + useEffect(() => { if (signingIn) { Login(username, password) .then(() => { setUsername(''); setPassword(''); + navigate(NavDashboard); }) .catch((error) => { setError(error); diff --git a/v1/frontend/src/screens/Start.css b/v1/frontend/src/screens/Start.css new file mode 100644 index 0000000..e69de29 diff --git a/v1/frontend/src/screens/Start.jsx b/v1/frontend/src/screens/Start.jsx new file mode 100644 index 0000000..e69de29 diff --git a/v1/internal/config/config.go b/v1/internal/config/config.go index f0c3dff..492ea49 100644 --- a/v1/internal/config/config.go +++ b/v1/internal/config/config.go @@ -11,6 +11,8 @@ const ( configDirNix = ".rum-goggles" configDirWin = "RumGoggles" + imageDir = "images" + logFile = "rumgoggles.log" sqlFile = "rumgoggles.db" ) @@ -32,6 +34,22 @@ func Database() (string, error) { return path, nil } +func ImageDir() (string, error) { + cfgDir, err := configDir() + if err != nil { + return "", pkgErr("error getting config directory", err) + } + + dir := filepath.Join(cfgDir, imageDir) + + err = os.MkdirAll(dir, 0750) + if err != nil { + return "", fmt.Errorf("error making directory: %v", err) + } + + return dir, nil +} + // TODO: implement log rotation // Rotate log file every week? // Keep most recent 4 logs? diff --git a/v1/internal/models/account.go b/v1/internal/models/account.go index 5548af7..05b7493 100644 --- a/v1/internal/models/account.go +++ b/v1/internal/models/account.go @@ -6,36 +6,59 @@ import ( ) const ( - accountColumns = "id, username, cookies" + accountColumns = "id, uid, username, cookies, profile_image, api_key" accountTable = "account" ) type Account struct { - ID *int64 - Username *string - Cookies *string + ID *int64 `json:"id"` + UID *string `json:"uid"` + Username *string `json:"username"` + Cookies *string `json:"cookies"` + ProfileImage *string `json:"profile_image"` + ApiKey *string `json:"api_key"` +} + +func (a *Account) values() []any { + return []any{a.ID, a.UID, a.Username, a.Cookies, a.ProfileImage, a.ApiKey} +} + +func (a *Account) valuesNoID() []any { + return a.values()[1:] +} + +func (a *Account) valuesEndID() []any { + vals := a.values() + return append(vals[1:], vals[0]) } type sqlAccount struct { - id sql.NullInt64 - username sql.NullString - cookies sql.NullString + id sql.NullInt64 + uid sql.NullString + username sql.NullString + cookies sql.NullString + profileImage sql.NullString + apiKey sql.NullString } func (sa *sqlAccount) scan(r Row) error { - return r.Scan(&sa.id, &sa.username, &sa.cookies) + return r.Scan(&sa.id, &sa.uid, &sa.username, &sa.cookies, &sa.profileImage, &sa.apiKey) } func (sa sqlAccount) toAccount() *Account { var a Account a.ID = toInt64(sa.id) + a.UID = toString(sa.uid) a.Username = toString(sa.username) a.Cookies = toString(sa.cookies) + a.ProfileImage = toString(sa.profileImage) + a.ApiKey = toString(sa.apiKey) return &a } type AccountService interface { + All() ([]Account, error) AutoMigrate() error ByUsername(username string) (*Account, error) Create(a *Account) error @@ -55,10 +78,41 @@ type accountService struct { Database *sql.DB } +func (as *accountService) All() ([]Account, error) { + selectQ := fmt.Sprintf(` + SELECT %s + FROM "%s" + `, accountColumns, accountTable) + + rows, err := as.Database.Query(selectQ) + if err != nil { + return nil, pkgErr("error executing select query", err) + } + defer rows.Close() + + accounts := []Account{} + for rows.Next() { + sa := &sqlAccount{} + + err = sa.scan(rows) + if err != nil { + return nil, pkgErr("error scanning row", err) + } + + accounts = append(accounts, *sa.toAccount()) + } + err = rows.Err() + if err != nil && err != sql.ErrNoRows { + return nil, pkgErr("error iterating over rows", err) + } + + return accounts, nil +} + func (as *accountService) AutoMigrate() error { err := as.createAccountTable() if err != nil { - return err + return pkgErr(fmt.Sprintf("error creating %s table", accountTable), err) } return nil @@ -68,14 +122,17 @@ func (as *accountService) createAccountTable() error { createQ := fmt.Sprintf(` CREATE TABLE IF NOT EXISTS "%s" ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + uid TEXT UNIQUE, username TEXT UNIQUE NOT NULL, - cookies TEXT + cookies TEXT, + profile_image TEXT, + api_key TEXT ) `, accountTable) _, err := as.Database.Exec(createQ) if err != nil { - return fmt.Errorf("error creating table: %v", err) + return fmt.Errorf("error executing create query: %v", err) } return nil @@ -103,7 +160,7 @@ func (as *accountService) ByUsername(username string) (*Account, error) { if err == sql.ErrNoRows { return nil, nil } - return nil, pkgErr(fmt.Sprintf("error querying \"%s\" by username", accountTable), err) + return nil, pkgErr("error executing select query", err) } return sa.toAccount(), nil @@ -124,22 +181,31 @@ func (as *accountService) Create(a *Account) error { VALUES (%s) `, accountTable, columns, values(columns)) - _, err = as.Database.Exec(insertQ, a.Username, a.Cookies) + _, err = as.Database.Exec(insertQ, a.valuesNoID()...) if err != nil { - return pkgErr(fmt.Sprintf("error inserting %s", accountTable), err) + return pkgErr("error executing insert query", err) } return nil } func (as *accountService) DestructiveReset() error { + err := as.dropAccountTable() + if err != nil { + return pkgErr(fmt.Sprintf("error dropping %s table", accountTable), err) + } + + return nil +} + +func (as *accountService) dropAccountTable() error { dropQ := fmt.Sprintf(` DROP TABLE IF EXISTS "%s" `, accountTable) _, err := as.Database.Exec(dropQ) if err != nil { - return fmt.Errorf("error dropping table: %v", err) + return fmt.Errorf("error executing drop query: %v", err) } return nil @@ -162,9 +228,9 @@ func (as *accountService) Update(a *Account) error { WHERE id=? `, accountTable, set(columns)) - _, err = as.Database.Exec(updateQ, a.Username, a.Cookies, a.ID) + _, err = as.Database.Exec(updateQ, a.valuesEndID()...) if err != nil { - return pkgErr(fmt.Sprintf("error updating %s", accountTable), err) + return pkgErr(fmt.Sprintf("error executing update query", accountTable), err) } return nil diff --git a/v1/internal/models/accountchannel.go b/v1/internal/models/accountchannel.go new file mode 100644 index 0000000..3ec36e2 --- /dev/null +++ b/v1/internal/models/accountchannel.go @@ -0,0 +1,94 @@ +package models + +import ( + "database/sql" + "fmt" +) + +const ( + accountChannelColumns = "a.id, a.uid, a.username, a.cookies, a.profile_image, a.api_key, c.id, c.account_id, c.cid, c.name, c.profile_image, c.api_key" +) + +type AccountChannel struct { + Account + Channel +} + +type sqlAccountChannel struct { + sqlAccount + sqlChannel +} + +func (sac *sqlAccountChannel) scan(r Row) error { + return r.Scan( + &sac.sqlAccount.id, + &sac.sqlAccount.uid, + &sac.sqlAccount.username, + &sac.sqlAccount.cookies, + &sac.sqlAccount.profileImage, + &sac.sqlAccount.apiKey, + &sac.sqlChannel.id, + &sac.sqlChannel.accountID, + &sac.sqlChannel.cid, + &sac.sqlChannel.name, + &sac.sqlChannel.profileImage, + &sac.sqlChannel.apiKey, + ) +} + +func (sac *sqlAccountChannel) toAccountChannel() *AccountChannel { + var ac AccountChannel + + ac.Account = *sac.toAccount() + ac.Channel = *sac.toChannel() + + return &ac +} + +type AccountChannelService interface { + All() ([]AccountChannel, error) +} + +func NewAccountChannelService(db *sql.DB) AccountChannelService { + return &accountChannelService{ + Database: db, + } +} + +var _ AccountChannelService = &accountChannelService{} + +type accountChannelService struct { + Database *sql.DB +} + +func (as *accountChannelService) All() ([]AccountChannel, error) { + selectQ := fmt.Sprintf(` + SELECT %s + FROM "%s" a + LEFT JOIN "%s" c ON a.id=c.account_id + `, accountChannelColumns, accountTable, channelTable) + + rows, err := as.Database.Query(selectQ) + if err != nil { + return nil, pkgErr("error executing select query", err) + } + defer rows.Close() + + accountChannels := []AccountChannel{} + for rows.Next() { + sac := &sqlAccountChannel{} + + err = sac.scan(rows) + if err != nil { + return nil, pkgErr("error scanning row", err) + } + + accountChannels = append(accountChannels, *sac.toAccountChannel()) + } + err = rows.Err() + if err != nil && err != sql.ErrNoRows { + return nil, pkgErr("error iterating over rows", err) + } + + return accountChannels, nil +} diff --git a/v1/internal/models/channel.go b/v1/internal/models/channel.go new file mode 100644 index 0000000..1e3fa3c --- /dev/null +++ b/v1/internal/models/channel.go @@ -0,0 +1,232 @@ +package models + +import ( + "database/sql" + "fmt" +) + +const ( + channelColumns = "id, account_id, cid, name, profile_image, api_key" + channelTable = "channel" +) + +type Channel struct { + ID *int64 `json:"id"` + AccountID *int64 `json:"account_id"` + CID *string `json:"cid"` + Name *string `json:"name"` + ProfileImage *string `json:"profile_image"` + ApiKey *string `json:"api_key"` +} + +func (c *Channel) values() []any { + return []any{c.ID, c.AccountID, c.CID, c.Name, c.ProfileImage, c.ApiKey} +} + +func (c *Channel) valuesNoID() []any { + return c.values()[1:] +} + +func (c *Channel) valuesEndID() []any { + vals := c.values() + return append(vals[1:], vals[0]) +} + +type sqlChannel struct { + id sql.NullInt64 + accountID sql.NullInt64 + cid sql.NullString + name sql.NullString + profileImage sql.NullString + apiKey sql.NullString +} + +func (sc *sqlChannel) scan(r Row) error { + return r.Scan(&sc.id, &sc.accountID, &sc.cid, &sc.name, &sc.profileImage, &sc.apiKey) +} + +func (sc sqlChannel) toChannel() *Channel { + var c Channel + c.ID = toInt64(sc.id) + c.AccountID = toInt64(sc.accountID) + c.CID = toString(sc.cid) + c.Name = toString(sc.name) + c.ProfileImage = toString(sc.profileImage) + c.ApiKey = toString(sc.apiKey) + + return &c +} + +type ChannelService interface { + AutoMigrate() error + ByName(name string) (*Channel, error) + Create(c *Channel) error + DestructiveReset() error +} + +func NewChannelService(db *sql.DB) ChannelService { + return &channelService{ + Database: db, + } +} + +var _ ChannelService = &channelService{} + +type channelService struct { + Database *sql.DB +} + +func (cs *channelService) AutoMigrate() error { + err := cs.createChannelTable() + if err != nil { + return pkgErr(fmt.Sprintf("error creating %s table", channelTable), err) + } + + return nil +} + +func (cs *channelService) createChannelTable() error { + createQ := fmt.Sprintf(` + CREATE TABLE IF NOT EXISTS "%s" ( + id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, + account_id INTEGER NOT NULL, + cid TEXT UNIQUE NOT NULL, + name TEXT UNIQUE NOT NULL, + profile_image TEXT, + api_key TEXT NOT NULL, + FOREIGN KEY (account_id) REFERENCES "%s" (id) + ) + `, channelTable, accountTable) + + _, err := cs.Database.Exec(createQ) + if err != nil { + return fmt.Errorf("error executing create query: %v", err) + } + + return nil +} + +func (cs *channelService) ByName(name string) (*Channel, error) { + err := runChannelValFuncs( + &Channel{Name: &name}, + channelRequireName, + ) + if err != nil { + return nil, pkgErr("", err) + } + + selectQ := fmt.Sprintf(` + SELECT %s + FROM "%s" + WHERE name=? + `, channelColumns, channelTable) + + var sc sqlChannel + row := cs.Database.QueryRow(selectQ, name) + err = sc.scan(row) + if err != nil { + if err == sql.ErrNoRows { + return nil, nil + } + return nil, pkgErr("error executing select query", err) + } + + return sc.toChannel(), nil +} + +func (cs *channelService) Create(c *Channel) error { + err := runChannelValFuncs( + c, + channelRequireAccountID, + channelRequireApiKey, + channelRequireCID, + channelRequireName, + ) + if err != nil { + return pkgErr("invalid channel", err) + } + + columns := columnsNoID(channelColumns) + insertQ := fmt.Sprintf(` + INSERT INTO "%s" (%s) + VALUES (%s) + `, channelTable, columns, values(columns)) + + _, err = cs.Database.Exec(insertQ, c.valuesNoID()...) + if err != nil { + return pkgErr("error executing insert query", err) + } + + return nil +} + +func (cs *channelService) DestructiveReset() error { + err := cs.dropChannelTable() + if err != nil { + return pkgErr(fmt.Sprintf("error dropping %s table", channelTable), err) + } + + return nil +} + +func (cs *channelService) dropChannelTable() error { + dropQ := fmt.Sprintf(` + DROP TABLE IF EXISTS "%s" + `, channelTable) + + _, err := cs.Database.Exec(dropQ) + if err != nil { + return fmt.Errorf("error executing drop query: %v", err) + } + + return nil +} + +type channelValFunc func(*Channel) error + +func runChannelValFuncs(c *Channel, fns ...channelValFunc) error { + if c == nil { + return fmt.Errorf("channel cannot be nil") + } + + for _, fn := range fns { + err := fn(c) + if err != nil { + return err + } + } + + return nil +} + +func channelRequireAccountID(c *Channel) error { + if c.AccountID == nil || *c.AccountID <= 0 { + return ErrChannelInvalidAccountID + } + + return nil +} + +func channelRequireApiKey(c *Channel) error { + if c.ApiKey == nil || *c.ApiKey == "" { + return ErrChannelInvalidApiKey + } + + return nil +} + +func channelRequireCID(c *Channel) error { + if c.CID == nil || *c.CID == "" { + return ErrChannelInvalidCID + } + + return nil +} + +func channelRequireName(c *Channel) error { + if c.Name == nil || *c.Name == "" { + return ErrChannelInvalidName + } + + return nil +} diff --git a/v1/internal/models/error.go b/v1/internal/models/error.go index 36a3e59..f011ab8 100644 --- a/v1/internal/models/error.go +++ b/v1/internal/models/error.go @@ -7,6 +7,11 @@ const ( ErrAccountInvalidUsername ValidatorError = "invalid account username" ErrAccountInvalidID ValidatorError = "invalid account id" + + ErrChannelInvalidAccountID ValidatorError = "invalid channel account id" + ErrChannelInvalidApiKey ValidatorError = "invalid channel API key" + ErrChannelInvalidCID ValidatorError = "invalid channel CID" + ErrChannelInvalidName ValidatorError = "invalid channel name" ) func pkgErr(prefix string, err error) error { diff --git a/v1/internal/models/services.go b/v1/internal/models/services.go index 8a93cb3..cccde7e 100644 --- a/v1/internal/models/services.go +++ b/v1/internal/models/services.go @@ -7,23 +7,27 @@ import ( type migrationFunc func() error -type service struct { +type table struct { name string automigrate migrationFunc destructivereset migrationFunc } type Services struct { - AccountS AccountService - Database *sql.DB - services []service + AccountS AccountService + AccountChannelS AccountChannelService + ChannelS ChannelService + Database *sql.DB + tables []table } func (s *Services) AutoMigrate() error { - for _, service := range s.services { - err := service.automigrate() - if err != nil { - return pkgErr(fmt.Sprintf("error auto-migrating %s service", service.name), err) + for _, table := range s.tables { + if table.automigrate != nil { + err := table.automigrate() + if err != nil { + return pkgErr(fmt.Sprintf("error auto-migrating %s table", table.name), err) + } } } @@ -40,10 +44,12 @@ func (s *Services) Close() error { } func (s *Services) DestructiveReset() error { - for _, service := range s.services { - err := service.destructivereset() - if err != nil { - return pkgErr(fmt.Sprintf("error destructive-resetting %s service", service.name), err) + for _, table := range s.tables { + if table.destructivereset != nil { + err := table.destructivereset() + if err != nil { + return pkgErr(fmt.Sprintf("error destructive-resetting %s table", table.name), err) + } } } @@ -78,7 +84,24 @@ func WithDatabase(file string) ServicesInit { func WithAccountService() ServicesInit { return func(s *Services) error { s.AccountS = NewAccountService(s.Database) - s.services = append(s.services, service{accountTable, s.AccountS.AutoMigrate, s.AccountS.DestructiveReset}) + s.tables = append(s.tables, table{accountTable, s.AccountS.AutoMigrate, s.AccountS.DestructiveReset}) + + return nil + } +} + +func WithAccountChannelService() ServicesInit { + return func(s *Services) error { + s.AccountChannelS = NewAccountChannelService(s.Database) + + return nil + } +} + +func WithChannelService() ServicesInit { + return func(s *Services) error { + s.ChannelS = NewChannelService(s.Database) + s.tables = append(s.tables, table{channelTable, s.ChannelS.AutoMigrate, s.ChannelS.DestructiveReset}) return nil } diff --git a/v1/main.go b/v1/main.go index 52f3064..7bc52ba 100644 --- a/v1/main.go +++ b/v1/main.go @@ -2,7 +2,10 @@ package main import ( "embed" + "net/http" + "strings" + "github.com/tylertravisty/rum-goggles/v1/internal/config" "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" @@ -21,7 +24,8 @@ func main() { Width: 1024, Height: 768, AssetServer: &assetserver.Options{ - Assets: assets, + Assets: assets, + Handler: http.HandlerFunc(GetImage), }, BackgroundColour: &options.RGBA{R: 255, G: 255, B: 255, A: 255}, OnShutdown: app.shutdown, @@ -35,3 +39,12 @@ func main() { println("Error:", err.Error()) } } + +func GetImage(w http.ResponseWriter, r *http.Request) { + path := strings.Replace(r.RequestURI, "wails://wails", "", 1) + prefix, err := config.ImageDir() + if err != nil { + return + } + http.ServeFile(w, r, prefix+path) +}