Implemented functionality to add accounts and channels
This commit is contained in:
		
							parent
							
								
									08d6bc3782
								
							
						
					
					
						commit
						e68567c010
					
				
							
								
								
									
										128
									
								
								v1/app.go
									
									
									
									
									
								
							
							
						
						
									
										128
									
								
								v1/app.go
									
									
									
									
									
								
							|  | @ -67,6 +67,8 @@ func (a *App) startup(ctx context.Context) { | ||||||
| 	services, err := models.NewServices( | 	services, err := models.NewServices( | ||||||
| 		models.WithDatabase(db), | 		models.WithDatabase(db), | ||||||
| 		models.WithAccountService(), | 		models.WithAccountService(), | ||||||
|  | 		models.WithChannelService(), | ||||||
|  | 		models.WithAccountChannelService(), | ||||||
| 	) | 	) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		log.Fatal(err) | 		log.Fatal(err) | ||||||
|  | @ -100,6 +102,84 @@ func (a *App) shutdown(ctx context.Context) { | ||||||
| 	a.logFileMu.Unlock() | 	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 { | func (a *App) Login(username string, password string) error { | ||||||
| 	var err error | 	var err error | ||||||
| 	client, exists := a.clients[username] | 	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.") | 		return fmt.Errorf("Error logging in. Try again.") | ||||||
| 	} | 	} | ||||||
| 	if act == nil { | 	if act == nil { | ||||||
| 		act = &models.Account{nil, &username, &cookiesS} | 		act = &models.Account{nil, nil, &username, &cookiesS, nil, nil} | ||||||
| 		err = a.services.AccountS.Create(act) | 		err = a.services.AccountS.Create(act) | ||||||
| 		if err != nil { | 		if err != nil { | ||||||
| 			a.logError.Println("error creating account:", err) | 			a.logError.Println("error creating account:", err) | ||||||
|  | @ -143,10 +223,54 @@ func (a *App) Login(username string, password string) error { | ||||||
| 		act.Cookies = &cookiesS | 		act.Cookies = &cookiesS | ||||||
| 		err = a.services.AccountS.Update(act) | 		err = a.services.AccountS.Update(act) | ||||||
| 		if err != nil { | 		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 fmt.Errorf("Error logging in. Try again.") | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	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 | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -1,7 +1,8 @@ | ||||||
| import { useState } from 'react'; | import { useState } from 'react'; | ||||||
| import { MemoryRouter as Router, Route, Routes, Link } from 'react-router-dom'; | import { MemoryRouter as Router, Route, Routes, Link } from 'react-router-dom'; | ||||||
| import './App.css'; | import './App.css'; | ||||||
| import { NavSignIn } from './Navigation'; | import { NavDashboard, NavSignIn } from './Navigation'; | ||||||
|  | import Dashboard from './screens/Dashboard'; | ||||||
| import SignIn from './screens/SignIn'; | import SignIn from './screens/SignIn'; | ||||||
| 
 | 
 | ||||||
| function App() { | function App() { | ||||||
|  | @ -9,6 +10,7 @@ function App() { | ||||||
|         <Router> |         <Router> | ||||||
|             <Routes> |             <Routes> | ||||||
|                 <Route path={NavSignIn} element={<SignIn />} /> |                 <Route path={NavSignIn} element={<SignIn />} /> | ||||||
|  |                 <Route path={NavDashboard} element={<Dashboard />} /> | ||||||
|             </Routes> |             </Routes> | ||||||
|         </Router> |         </Router> | ||||||
|     ); |     ); | ||||||
|  |  | ||||||
|  | @ -1 +1,2 @@ | ||||||
|  | export const NavDashboard = '/dashboard'; | ||||||
| export const NavSignIn = '/'; | export const NavSignIn = '/'; | ||||||
|  |  | ||||||
							
								
								
									
										
											BIN
										
									
								
								v1/frontend/src/assets/icons/chevron-right.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								v1/frontend/src/assets/icons/chevron-right.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 2.2 KiB | 
							
								
								
									
										
											BIN
										
									
								
								v1/frontend/src/assets/icons/circle-green-background.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								v1/frontend/src/assets/icons/circle-green-background.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 6.6 KiB | 
							
								
								
									
										
											BIN
										
									
								
								v1/frontend/src/assets/icons/circle-red-background.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								v1/frontend/src/assets/icons/circle-red-background.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 6.5 KiB | 
							
								
								
									
										
											BIN
										
									
								
								v1/frontend/src/assets/icons/heart-fill.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								v1/frontend/src/assets/icons/heart-fill.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 4.8 KiB | 
							
								
								
									
										
											BIN
										
									
								
								v1/frontend/src/assets/icons/plus-circle-fill.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								v1/frontend/src/assets/icons/plus-circle-fill.png
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| After Width: | Height: | Size: 4.8 KiB | 
|  | @ -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 from './icons/eye.png'; | ||||||
| import eye_slash from './icons/eye-slash.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 x_lg from './icons/x-lg.png'; | ||||||
| import logo from './logo/logo.png'; | import logo from './logo/logo.png'; | ||||||
| 
 | 
 | ||||||
|  | export const ChevronRight = chevron_right; | ||||||
|  | export const CircleGreenBackground = circle_green_background; | ||||||
| export const Eye = eye; | export const Eye = eye; | ||||||
| export const EyeSlash = eye_slash; | export const EyeSlash = eye_slash; | ||||||
|  | export const Heart = heart; | ||||||
| export const Logo = logo; | export const Logo = logo; | ||||||
|  | export const PlusCircle = plus_circle; | ||||||
| export const XLg = x_lg; | export const XLg = x_lg; | ||||||
							
								
								
									
										357
									
								
								v1/frontend/src/components/ChannelSideBar.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										357
									
								
								v1/frontend/src/components/ChannelSideBar.css
									
									
									
									
									
										Normal file
									
								
							|  | @ -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: <div class="loader"></div> */ | ||||||
|  | .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%  } | ||||||
|  | } | ||||||
							
								
								
									
										503
									
								
								v1/frontend/src/components/ChannelSideBar.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										503
									
								
								v1/frontend/src/components/ChannelSideBar.jsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 ( | ||||||
|  |         <> | ||||||
|  |             <ModalAdd | ||||||
|  |                 onClose={() => setAddOpen(false)} | ||||||
|  |                 onRefresh={() => { | ||||||
|  |                     setRefresh(!refresh); | ||||||
|  |                 }} | ||||||
|  |                 show={addOpen} | ||||||
|  |             /> | ||||||
|  |             <div className='channel-sidebar'> | ||||||
|  |                 <div className='channel-sidebar-body' onScroll={handleScroll}> | ||||||
|  |                     {sortAccounts().map((account, index) => ( | ||||||
|  |                         <AccountChannels | ||||||
|  |                             account={accounts[account]} | ||||||
|  |                             key={index} | ||||||
|  |                             scrollY={scrollY} | ||||||
|  |                             top={index === 0} | ||||||
|  |                         /> | ||||||
|  |                     ))} | ||||||
|  |                 </div> | ||||||
|  |                 <div className='channel-sidebar-footer'> | ||||||
|  |                     <ButtonIcon | ||||||
|  |                         hoverText={'Add an account/channel'} | ||||||
|  |                         onClick={() => setAddOpen(true)} | ||||||
|  |                         scrollY={0} | ||||||
|  |                     /> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |         </> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 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 ( | ||||||
|  |             <div | ||||||
|  |                 className='channel-sidebar-account-list' | ||||||
|  |                 style={props.top ? { borderTop: 'none' } : {}} | ||||||
|  |             > | ||||||
|  |                 <AccountIcon account={props.account.account} key={0} scrollY={props.scrollY} /> | ||||||
|  |                 {sortChannels().map((channel, index) => ( | ||||||
|  |                     <ChannelIcon channel={channel} key={index + 1} scrollY={props.scrollY} /> | ||||||
|  |                 ))} | ||||||
|  |             </div> | ||||||
|  |         ); | ||||||
|  |     } | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function AccountIcon(props) { | ||||||
|  |     const [hover, setHover] = useState(false); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <div | ||||||
|  |             className='channel-sidebar-icon' | ||||||
|  |             onMouseEnter={() => setHover(true)} | ||||||
|  |             onMouseLeave={() => setHover(false)} | ||||||
|  |         > | ||||||
|  |             {props.account.profile_image === null ? ( | ||||||
|  |                 <span className='channel-sidebar-icon-initial'> | ||||||
|  |                     {props.account.username[0].toUpperCase()} | ||||||
|  |                 </span> | ||||||
|  |             ) : ( | ||||||
|  |                 <img className='channel-sidebar-icon-image' src={props.account.profile_image} /> | ||||||
|  |             )} | ||||||
|  |             <img className='channel-sidebar-icon-account' src={CircleGreenBackground} /> | ||||||
|  |             {hover && ( | ||||||
|  |                 <HoverName name={'/user/' + props.account.username} scrollY={props.scrollY} /> | ||||||
|  |             )} | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function ButtonIcon(props) { | ||||||
|  |     const [hover, setHover] = useState(false); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <div | ||||||
|  |             className='channel-sidebar-icon' | ||||||
|  |             onMouseEnter={() => setHover(true)} | ||||||
|  |             onMouseLeave={() => setHover(false)} | ||||||
|  |         > | ||||||
|  |             <button className='channel-sidebar-button' onClick={props.onClick}> | ||||||
|  |                 <img className='channel-sidebar-button-icon' src={PlusCircle} /> | ||||||
|  |             </button> | ||||||
|  |             {hover && <HoverName name={props.hoverText} scrollY={props.scrollY} />} | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function ChannelIcon(props) { | ||||||
|  |     const [hover, setHover] = useState(false); | ||||||
|  |     return ( | ||||||
|  |         <div | ||||||
|  |             className='channel-sidebar-icon' | ||||||
|  |             onMouseEnter={() => setHover(true)} | ||||||
|  |             onMouseLeave={() => setHover(false)} | ||||||
|  |         > | ||||||
|  |             {props.channel.profile_image === null ? ( | ||||||
|  |                 <span className='channel-sidebar-icon-initial'> | ||||||
|  |                     {props.channel.name[0].toUpperCase()} | ||||||
|  |                 </span> | ||||||
|  |             ) : ( | ||||||
|  |                 <img className='channel-sidebar-icon-image' src={props.channel.profile_image} /> | ||||||
|  |             )} | ||||||
|  |             {hover && ( | ||||||
|  |                 <HoverName | ||||||
|  |                     name={'/c/' + props.channel.name.replace(/\s/g, '')} | ||||||
|  |                     scrollY={props.scrollY} | ||||||
|  |                 /> | ||||||
|  |             )} | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function HoverName(props) { | ||||||
|  |     return ( | ||||||
|  |         <div | ||||||
|  |             className='channel-sidebar-icon-hover' | ||||||
|  |             style={{ transform: 'translate(75px, -' + (50 + props.scrollY) + 'px)' }} | ||||||
|  |         > | ||||||
|  |             <span className='channel-sidebar-icon-hover-text'>{props.name}</span> | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | 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 ( | ||||||
|  |         <> | ||||||
|  |             <SmallModal | ||||||
|  |                 onClose={() => setError('')} | ||||||
|  |                 show={error !== ''} | ||||||
|  |                 style={{ minWidth: '300px', maxWidth: '200px', maxHeight: '200px' }} | ||||||
|  |                 title={'Error'} | ||||||
|  |                 message={error} | ||||||
|  |                 submitButton={'OK'} | ||||||
|  |                 onSubmit={() => setError('')} | ||||||
|  |             /> | ||||||
|  |             <Modal | ||||||
|  |                 cancelButton={stage !== 'start' ? 'Back' : ''} | ||||||
|  |                 onCancel={back} | ||||||
|  |                 onClose={close} | ||||||
|  |                 show={props.show} | ||||||
|  |                 style={{ height: '480px', minHeight: '480px', width: '360px', minWidth: '360px' }} | ||||||
|  |                 submitButton={stage !== 'start' ? 'Add' : ''} | ||||||
|  |                 submitLoading={loading()} | ||||||
|  |                 onSubmit={add} | ||||||
|  |             > | ||||||
|  |                 {stage === 'start' && <ModalAddStart setStage={setStage} />} | ||||||
|  |                 {stage === 'account' && ( | ||||||
|  |                     <ModalAddAccount | ||||||
|  |                         accountPassword={accountPassword} | ||||||
|  |                         accountPasswordValid={accountPasswordValid} | ||||||
|  |                         updateAccountPassword={updateAccountPassword} | ||||||
|  |                         accountUsername={accountUsername} | ||||||
|  |                         accountUsernameValid={accountUsernameValid} | ||||||
|  |                         updateAccountUsername={updateAccountUsername} | ||||||
|  |                     /> | ||||||
|  |                 )} | ||||||
|  |                 {stage === 'channel' && ( | ||||||
|  |                     <ModalAddChannel | ||||||
|  |                         channelKey={channelKey} | ||||||
|  |                         channelKeyValid={channelKeyValid} | ||||||
|  |                         updateChannelKey={updateChannelKey} | ||||||
|  |                     /> | ||||||
|  |                 )} | ||||||
|  |             </Modal> | ||||||
|  |         </> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function ModalAddAccount(props) { | ||||||
|  |     const [showKey, setShowKey] = useState(false); | ||||||
|  |     const updateShowKey = () => setShowKey(!showKey); | ||||||
|  |     const [showPassword, setShowPassword] = useState(false); | ||||||
|  |     const updateShowPassword = () => setShowPassword(!showPassword); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <div className='modal-add-account-channel'> | ||||||
|  |             <div className='modal-add-account-channel-header'> | ||||||
|  |                 <span className='modal-add-account-channel-title'>Add Account</span> | ||||||
|  |                 <span className='modal-add-account-channel-subtitle'> | ||||||
|  |                     Log into your Rumble account | ||||||
|  |                 </span> | ||||||
|  |             </div> | ||||||
|  |             <div className='modal-add-account-channel-body'> | ||||||
|  |                 {props.accountUsernameValid === false ? ( | ||||||
|  |                     <label className='modal-add-account-channel-label-warning'> | ||||||
|  |                         USERNAME - Please enter a valid username | ||||||
|  |                     </label> | ||||||
|  |                 ) : ( | ||||||
|  |                     <label className='modal-add-account-channel-label'>USERNAME</label> | ||||||
|  |                 )} | ||||||
|  |                 <div className='modal-add-account-channel-input'> | ||||||
|  |                     <input | ||||||
|  |                         className='modal-add-account-channel-input-text' | ||||||
|  |                         onChange={!props.loading && props.updateAccountUsername} | ||||||
|  |                         placeholder={'Username'} | ||||||
|  |                         type={'text'} | ||||||
|  |                         value={props.accountUsername} | ||||||
|  |                     ></input> | ||||||
|  |                 </div> | ||||||
|  |                 {props.accountPasswordValid === false ? ( | ||||||
|  |                     <label className='modal-add-account-channel-label-warning'> | ||||||
|  |                         PASSWORD - Please enter a valid password | ||||||
|  |                     </label> | ||||||
|  |                 ) : ( | ||||||
|  |                     <label className='modal-add-account-channel-label'>PASSWORD</label> | ||||||
|  |                 )} | ||||||
|  |                 <div className='modal-add-account-channel-input'> | ||||||
|  |                     <input | ||||||
|  |                         className='modal-add-account-channel-input-password' | ||||||
|  |                         onChange={!props.loading && props.updateAccountPassword} | ||||||
|  |                         placeholder={'Password'} | ||||||
|  |                         type={showPassword ? 'text' : 'password'} | ||||||
|  |                         value={props.accountPassword} | ||||||
|  |                     ></input> | ||||||
|  |                     <button | ||||||
|  |                         className='modal-add-account-channel-input-show' | ||||||
|  |                         onClick={updateShowPassword} | ||||||
|  |                     > | ||||||
|  |                         <img | ||||||
|  |                             className='modal-add-account-channel-input-show-icon' | ||||||
|  |                             src={showPassword ? EyeSlash : Eye} | ||||||
|  |                         /> | ||||||
|  |                     </button> | ||||||
|  |                 </div> | ||||||
|  |             </div> | ||||||
|  |             <div></div> | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function ModalAddChannel(props) { | ||||||
|  |     const [showKey, setShowKey] = useState(false); | ||||||
|  |     const updateShowKey = () => setShowKey(!showKey); | ||||||
|  | 
 | ||||||
|  |     return ( | ||||||
|  |         <div className='modal-add-account-channel'> | ||||||
|  |             <div className='modal-add-account-channel-header'> | ||||||
|  |                 <span className='modal-add-account-channel-title'>Add Channel</span> | ||||||
|  |                 <span className='modal-add-account-channel-subtitle'> | ||||||
|  |                     Copy an API key below to add a channel | ||||||
|  |                 </span> | ||||||
|  |             </div> | ||||||
|  |             <div className='modal-add-account-channel-body'> | ||||||
|  |                 {props.channelKeyValid === false ? ( | ||||||
|  |                     <label className='modal-add-channel-label-warning'> | ||||||
|  |                         API KEY - Please enter a valid API key | ||||||
|  |                     </label> | ||||||
|  |                 ) : ( | ||||||
|  |                     <label className='modal-add-channel-label'>API KEY</label> | ||||||
|  |                 )} | ||||||
|  |                 <div className='modal-add-channel-key'> | ||||||
|  |                     <input | ||||||
|  |                         className='modal-add-channel-key-input' | ||||||
|  |                         onChange={!props.loading && props.updateChannelKey} | ||||||
|  |                         placeholder={'Enter API key'} | ||||||
|  |                         type={showKey ? 'text' : 'password'} | ||||||
|  |                         value={props.channelKey} | ||||||
|  |                     ></input> | ||||||
|  |                     <button className='modal-add-channel-key-show' onClick={updateShowKey}> | ||||||
|  |                         <img | ||||||
|  |                             className='modal-add-channel-key-show-icon' | ||||||
|  |                             src={showKey ? EyeSlash : Eye} | ||||||
|  |                         /> | ||||||
|  |                     </button> | ||||||
|  |                 </div> | ||||||
|  |                 <span className='modal-add-channel-description'>API KEYS SHOULD LOOK LIKE</span> | ||||||
|  |                 <span className='modal-add-channel-description-subtext'> | ||||||
|  |                     https://rumble.com/-livestream-api/get-data?key=really-long_string-of_random-characters | ||||||
|  |                 </span> | ||||||
|  |             </div> | ||||||
|  |             <div></div> | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function ModalAddStart(props) { | ||||||
|  |     return ( | ||||||
|  |         <div className='modal-add-account-channel'> | ||||||
|  |             <span className='modal-add-account-channel-title'>Add an Account or Channel</span> | ||||||
|  |             <div className='modal-add-account-channel-body'> | ||||||
|  |                 <button | ||||||
|  |                     className='modal-add-account-channel-button' | ||||||
|  |                     onClick={() => props.setStage('account')} | ||||||
|  |                 > | ||||||
|  |                     <div className='modal-add-account-channel-button-left'> | ||||||
|  |                         <span>Add Account</span> | ||||||
|  |                     </div> | ||||||
|  |                     <img | ||||||
|  |                         className='modal-add-account-channel-button-right-icon' | ||||||
|  |                         src={ChevronRight} | ||||||
|  |                     /> | ||||||
|  |                 </button> | ||||||
|  |                 <button | ||||||
|  |                     className='modal-add-account-channel-button' | ||||||
|  |                     onClick={() => props.setStage('channel')} | ||||||
|  |                 > | ||||||
|  |                     <div className='modal-add-account-channel-button-left'> | ||||||
|  |                         <span>Add Channel</span> | ||||||
|  |                     </div> | ||||||
|  |                     <img | ||||||
|  |                         className='modal-add-account-channel-button-right-icon' | ||||||
|  |                         src={ChevronRight} | ||||||
|  |                     /> | ||||||
|  |                 </button> | ||||||
|  |             </div> | ||||||
|  |             <div></div> | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  | @ -1,7 +1,8 @@ | ||||||
| 
 | 
 | ||||||
| .modal-background { | .modal-background { | ||||||
|     align-items: center; |     align-items: center; | ||||||
|     background-color: transparent; |     /* background-color: transparent; */ | ||||||
|  |     background-color: rgba(0,0,0,0.8); | ||||||
|     display: flex; |     display: flex; | ||||||
|     height: 100vh; |     height: 100vh; | ||||||
|     justify-content: center; |     justify-content: center; | ||||||
|  | @ -29,6 +30,7 @@ | ||||||
|     font-weight: bold; |     font-weight: bold; | ||||||
|     text-decoration: none; |     text-decoration: none; | ||||||
|     /* width: 20%; */ |     /* width: 20%; */ | ||||||
|  |     height: 40px; | ||||||
|     width: 70px; |     width: 70px; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -36,12 +38,14 @@ | ||||||
|     background-color: transparent; |     background-color: transparent; | ||||||
|     border: 1px solid #495a6a; |     border: 1px solid #495a6a; | ||||||
|     border-radius: 5px; |     border-radius: 5px; | ||||||
|     color: #495a6a; |     /* color: #495a6a; */ | ||||||
|  |     color: white; | ||||||
|     cursor: pointer; |     cursor: pointer; | ||||||
|     font-size: 18px; |     font-size: 18px; | ||||||
|     font-weight: bold; |     font-weight: bold; | ||||||
|     text-decoration: none; |     text-decoration: none; | ||||||
|     /* width: 20%; */ |     /* width: 20%; */ | ||||||
|  |     height: 40px; | ||||||
|     width: 70px; |     width: 70px; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -55,6 +59,7 @@ | ||||||
|     font-weight: bold; |     font-weight: bold; | ||||||
|     text-decoration: none; |     text-decoration: none; | ||||||
|     /* width: 20%; */ |     /* width: 20%; */ | ||||||
|  |     height: 40px; | ||||||
|     width: 70px; |     width: 70px; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | @ -79,8 +84,8 @@ | ||||||
| 
 | 
 | ||||||
| .modal-container { | .modal-container { | ||||||
|     align-items: center; |     align-items: center; | ||||||
|     background-color: rgba(6,23,38,1); |     background-color: #1f2e3c; | ||||||
|     border: 1px solid #495a6a; |     /* border: 1px solid #495a6a; */ | ||||||
|     border-radius: 15px; |     border-radius: 15px; | ||||||
|     color: black; |     color: black; | ||||||
|     display: flex; |     display: flex; | ||||||
|  | @ -133,7 +138,7 @@ | ||||||
|     align-items: center; |     align-items: center; | ||||||
|     /* background-color: rgba(6,23,38,1); */ |     /* background-color: rgba(6,23,38,1); */ | ||||||
|     background-color: white; |     background-color: white; | ||||||
|     border: 1px solid #495a6a; |     /* border: 1px solid #495a6a; */ | ||||||
|     /* border: 1px solid black; */ |     /* border: 1px solid black; */ | ||||||
|     border-radius: 15px; |     border-radius: 15px; | ||||||
|     color: black; |     color: black; | ||||||
|  |  | ||||||
|  | @ -6,7 +6,7 @@ export function Modal(props) { | ||||||
|         <div |         <div | ||||||
|             className='modal-background' |             className='modal-background' | ||||||
|             onClick={props.onClose} |             onClick={props.onClose} | ||||||
|             style={{ zIndex: props.show ? 10 : -10 }} |             style={{ zIndex: props.show ? 8 : -8 }} | ||||||
|         > |         > | ||||||
|             <div |             <div | ||||||
|                 className='modal-container' |                 className='modal-container' | ||||||
|  | @ -33,7 +33,12 @@ export function Modal(props) { | ||||||
|                     )} |                     )} | ||||||
|                     {props.submitButton && ( |                     {props.submitButton && ( | ||||||
|                         <button className='modal-button' onClick={props.onSubmit}> |                         <button className='modal-button' onClick={props.onSubmit}> | ||||||
|                             {props.submitButton} |                             {/* {props.submitButton} */} | ||||||
|  |                             {props.submitLoading ? ( | ||||||
|  |                                 <div className='loader'></div> | ||||||
|  |                             ) : ( | ||||||
|  |                                 props.submitButton | ||||||
|  |                             )} | ||||||
|                         </button> |                         </button> | ||||||
|                     )} |                     )} | ||||||
|                 </div> |                 </div> | ||||||
|  |  | ||||||
							
								
								
									
										7
									
								
								v1/frontend/src/screens/Dashboard.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								v1/frontend/src/screens/Dashboard.css
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,7 @@ | ||||||
|  | .dashboard { | ||||||
|  |     align-items: center; | ||||||
|  |     display: flex; | ||||||
|  |     flex-direction: row; | ||||||
|  |     height: 100vh; | ||||||
|  |     width: 100%; | ||||||
|  | } | ||||||
							
								
								
									
										14
									
								
								v1/frontend/src/screens/Dashboard.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								v1/frontend/src/screens/Dashboard.jsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,14 @@ | ||||||
|  | import { CircleGreenBackground, Heart } from '../assets'; | ||||||
|  | import ChannelSideBar from '../components/ChannelSideBar'; | ||||||
|  | import './Dashboard.css'; | ||||||
|  | 
 | ||||||
|  | function Dashboard() { | ||||||
|  |     return ( | ||||||
|  |         <div className='dashboard'> | ||||||
|  |             <ChannelSideBar /> | ||||||
|  |             <div style={{ backgroundColor: '#1f2e3c', width: '100%', height: '100%' }}></div> | ||||||
|  |         </div> | ||||||
|  |     ); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | export default Dashboard; | ||||||
|  | @ -1,11 +1,14 @@ | ||||||
| import { useEffect, useState } from 'react'; | import { useEffect, useState } from 'react'; | ||||||
| import { SmallModal } from '../components/Modal'; | 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 { Eye, EyeSlash, Logo } from '../assets'; | ||||||
|  | import { Navigate, useNavigate } from 'react-router-dom'; | ||||||
| import './SignIn.css'; | import './SignIn.css'; | ||||||
|  | import { NavDashboard } from '../Navigation'; | ||||||
| 
 | 
 | ||||||
| function SignIn() { | function SignIn() { | ||||||
|     const [error, setError] = useState(''); |     const [error, setError] = useState(''); | ||||||
|  |     const navigate = useNavigate(); | ||||||
|     const [password, setPassword] = useState(''); |     const [password, setPassword] = useState(''); | ||||||
|     const updatePassword = (event) => setPassword(event.target.value); |     const updatePassword = (event) => setPassword(event.target.value); | ||||||
|     const [showPassword, setShowPassword] = useState(false); |     const [showPassword, setShowPassword] = useState(false); | ||||||
|  | @ -14,12 +17,25 @@ function SignIn() { | ||||||
|     const [username, setUsername] = useState(''); |     const [username, setUsername] = useState(''); | ||||||
|     const updateUsername = (event) => setUsername(event.target.value); |     const updateUsername = (event) => setUsername(event.target.value); | ||||||
| 
 | 
 | ||||||
|  |     useEffect(() => { | ||||||
|  |         SignedIn() | ||||||
|  |             .then((signedIn) => { | ||||||
|  |                 if (signedIn) { | ||||||
|  |                     navigate(NavDashboard); | ||||||
|  |                 } | ||||||
|  |             }) | ||||||
|  |             .catch((error) => { | ||||||
|  |                 setError(error); | ||||||
|  |             }); | ||||||
|  |     }, []); | ||||||
|  | 
 | ||||||
|     useEffect(() => { |     useEffect(() => { | ||||||
|         if (signingIn) { |         if (signingIn) { | ||||||
|             Login(username, password) |             Login(username, password) | ||||||
|                 .then(() => { |                 .then(() => { | ||||||
|                     setUsername(''); |                     setUsername(''); | ||||||
|                     setPassword(''); |                     setPassword(''); | ||||||
|  |                     navigate(NavDashboard); | ||||||
|                 }) |                 }) | ||||||
|                 .catch((error) => { |                 .catch((error) => { | ||||||
|                     setError(error); |                     setError(error); | ||||||
|  |  | ||||||
							
								
								
									
										0
									
								
								v1/frontend/src/screens/Start.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								v1/frontend/src/screens/Start.css
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										0
									
								
								v1/frontend/src/screens/Start.jsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								v1/frontend/src/screens/Start.jsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -11,6 +11,8 @@ const ( | ||||||
| 	configDirNix = ".rum-goggles" | 	configDirNix = ".rum-goggles" | ||||||
| 	configDirWin = "RumGoggles" | 	configDirWin = "RumGoggles" | ||||||
| 
 | 
 | ||||||
|  | 	imageDir = "images" | ||||||
|  | 
 | ||||||
| 	logFile = "rumgoggles.log" | 	logFile = "rumgoggles.log" | ||||||
| 	sqlFile = "rumgoggles.db" | 	sqlFile = "rumgoggles.db" | ||||||
| ) | ) | ||||||
|  | @ -32,6 +34,22 @@ func Database() (string, error) { | ||||||
| 	return path, nil | 	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
 | // TODO: implement log rotation
 | ||||||
| // Rotate log file every week?
 | // Rotate log file every week?
 | ||||||
| // Keep most recent 4 logs?
 | // Keep most recent 4 logs?
 | ||||||
|  |  | ||||||
|  | @ -6,36 +6,59 @@ import ( | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| const ( | const ( | ||||||
| 	accountColumns = "id, username, cookies" | 	accountColumns = "id, uid, username, cookies, profile_image, api_key" | ||||||
| 	accountTable   = "account" | 	accountTable   = "account" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| type Account struct { | type Account struct { | ||||||
| 	ID       *int64 | 	ID           *int64  `json:"id"` | ||||||
| 	Username *string | 	UID          *string `json:"uid"` | ||||||
| 	Cookies  *string | 	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 { | type sqlAccount struct { | ||||||
| 	id           sql.NullInt64 | 	id           sql.NullInt64 | ||||||
|  | 	uid          sql.NullString | ||||||
| 	username     sql.NullString | 	username     sql.NullString | ||||||
| 	cookies      sql.NullString | 	cookies      sql.NullString | ||||||
|  | 	profileImage sql.NullString | ||||||
|  | 	apiKey       sql.NullString | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (sa *sqlAccount) scan(r Row) error { | 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 { | func (sa sqlAccount) toAccount() *Account { | ||||||
| 	var a Account | 	var a Account | ||||||
| 	a.ID = toInt64(sa.id) | 	a.ID = toInt64(sa.id) | ||||||
|  | 	a.UID = toString(sa.uid) | ||||||
| 	a.Username = toString(sa.username) | 	a.Username = toString(sa.username) | ||||||
| 	a.Cookies = toString(sa.cookies) | 	a.Cookies = toString(sa.cookies) | ||||||
|  | 	a.ProfileImage = toString(sa.profileImage) | ||||||
|  | 	a.ApiKey = toString(sa.apiKey) | ||||||
| 
 | 
 | ||||||
| 	return &a | 	return &a | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| type AccountService interface { | type AccountService interface { | ||||||
|  | 	All() ([]Account, error) | ||||||
| 	AutoMigrate() error | 	AutoMigrate() error | ||||||
| 	ByUsername(username string) (*Account, error) | 	ByUsername(username string) (*Account, error) | ||||||
| 	Create(a *Account) error | 	Create(a *Account) error | ||||||
|  | @ -55,10 +78,41 @@ type accountService struct { | ||||||
| 	Database *sql.DB | 	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 { | func (as *accountService) AutoMigrate() error { | ||||||
| 	err := as.createAccountTable() | 	err := as.createAccountTable() | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return err | 		return pkgErr(fmt.Sprintf("error creating %s table", accountTable), err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return nil | ||||||
|  | @ -68,14 +122,17 @@ func (as *accountService) createAccountTable() error { | ||||||
| 	createQ := fmt.Sprintf(` | 	createQ := fmt.Sprintf(` | ||||||
| 		CREATE TABLE IF NOT EXISTS "%s" ( | 		CREATE TABLE IF NOT EXISTS "%s" ( | ||||||
| 			id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | 			id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | ||||||
|  | 			uid TEXT UNIQUE, | ||||||
| 			username TEXT UNIQUE NOT NULL, | 			username TEXT UNIQUE NOT NULL, | ||||||
| 			cookies TEXT | 			cookies TEXT, | ||||||
|  | 			profile_image TEXT, | ||||||
|  | 			api_key TEXT | ||||||
| 		) | 		) | ||||||
| 	`, accountTable) | 	`, accountTable) | ||||||
| 
 | 
 | ||||||
| 	_, err := as.Database.Exec(createQ) | 	_, err := as.Database.Exec(createQ) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fmt.Errorf("error creating table: %v", err) | 		return fmt.Errorf("error executing create query: %v", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return nil | ||||||
|  | @ -103,7 +160,7 @@ func (as *accountService) ByUsername(username string) (*Account, error) { | ||||||
| 		if err == sql.ErrNoRows { | 		if err == sql.ErrNoRows { | ||||||
| 			return nil, nil | 			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 | 	return sa.toAccount(), nil | ||||||
|  | @ -124,22 +181,31 @@ func (as *accountService) Create(a *Account) error { | ||||||
| 		VALUES (%s) | 		VALUES (%s) | ||||||
| 	`, accountTable, columns, values(columns)) | 	`, accountTable, columns, values(columns)) | ||||||
| 
 | 
 | ||||||
| 	_, err = as.Database.Exec(insertQ, a.Username, a.Cookies) | 	_, err = as.Database.Exec(insertQ, a.valuesNoID()...) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return pkgErr(fmt.Sprintf("error inserting %s", accountTable), err) | 		return pkgErr("error executing insert query", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (as *accountService) DestructiveReset() error { | 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(` | 	dropQ := fmt.Sprintf(` | ||||||
| 		DROP TABLE IF EXISTS "%s" | 		DROP TABLE IF EXISTS "%s" | ||||||
| 	`, accountTable) | 	`, accountTable) | ||||||
| 
 | 
 | ||||||
| 	_, err := as.Database.Exec(dropQ) | 	_, err := as.Database.Exec(dropQ) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return fmt.Errorf("error dropping table: %v", err) | 		return fmt.Errorf("error executing drop query: %v", err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return nil | ||||||
|  | @ -162,9 +228,9 @@ func (as *accountService) Update(a *Account) error { | ||||||
| 		WHERE id=? | 		WHERE id=? | ||||||
| 	`, accountTable, set(columns)) | 	`, accountTable, set(columns)) | ||||||
| 
 | 
 | ||||||
| 	_, err = as.Database.Exec(updateQ, a.Username, a.Cookies, a.ID) | 	_, err = as.Database.Exec(updateQ, a.valuesEndID()...) | ||||||
| 	if err != nil { | 	if err != nil { | ||||||
| 		return pkgErr(fmt.Sprintf("error updating %s", accountTable), err) | 		return pkgErr(fmt.Sprintf("error executing update query", accountTable), err) | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
| 	return nil | 	return nil | ||||||
|  |  | ||||||
							
								
								
									
										94
									
								
								v1/internal/models/accountchannel.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										94
									
								
								v1/internal/models/accountchannel.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||||
|  | } | ||||||
							
								
								
									
										232
									
								
								v1/internal/models/channel.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										232
									
								
								v1/internal/models/channel.go
									
									
									
									
									
										Normal file
									
								
							|  | @ -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 | ||||||
|  | } | ||||||
|  | @ -7,6 +7,11 @@ const ( | ||||||
| 
 | 
 | ||||||
| 	ErrAccountInvalidUsername ValidatorError = "invalid account username" | 	ErrAccountInvalidUsername ValidatorError = "invalid account username" | ||||||
| 	ErrAccountInvalidID       ValidatorError = "invalid account id" | 	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 { | func pkgErr(prefix string, err error) error { | ||||||
|  |  | ||||||
|  | @ -7,7 +7,7 @@ import ( | ||||||
| 
 | 
 | ||||||
| type migrationFunc func() error | type migrationFunc func() error | ||||||
| 
 | 
 | ||||||
| type service struct { | type table struct { | ||||||
| 	name             string | 	name             string | ||||||
| 	automigrate      migrationFunc | 	automigrate      migrationFunc | ||||||
| 	destructivereset migrationFunc | 	destructivereset migrationFunc | ||||||
|  | @ -15,15 +15,19 @@ type service struct { | ||||||
| 
 | 
 | ||||||
| type Services struct { | type Services struct { | ||||||
| 	AccountS        AccountService | 	AccountS        AccountService | ||||||
|  | 	AccountChannelS AccountChannelService | ||||||
|  | 	ChannelS        ChannelService | ||||||
| 	Database        *sql.DB | 	Database        *sql.DB | ||||||
| 	services []service | 	tables          []table | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| func (s *Services) AutoMigrate() error { | func (s *Services) AutoMigrate() error { | ||||||
| 	for _, service := range s.services { | 	for _, table := range s.tables { | ||||||
| 		err := service.automigrate() | 		if table.automigrate != nil { | ||||||
|  | 			err := table.automigrate() | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 			return pkgErr(fmt.Sprintf("error auto-migrating %s service", service.name), err) | 				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 { | func (s *Services) DestructiveReset() error { | ||||||
| 	for _, service := range s.services { | 	for _, table := range s.tables { | ||||||
| 		err := service.destructivereset() | 		if table.destructivereset != nil { | ||||||
|  | 			err := table.destructivereset() | ||||||
| 			if err != nil { | 			if err != nil { | ||||||
| 			return pkgErr(fmt.Sprintf("error destructive-resetting %s service", service.name), err) | 				return pkgErr(fmt.Sprintf("error destructive-resetting %s table", table.name), err) | ||||||
|  | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | @ -78,7 +84,24 @@ func WithDatabase(file string) ServicesInit { | ||||||
| func WithAccountService() ServicesInit { | func WithAccountService() ServicesInit { | ||||||
| 	return func(s *Services) error { | 	return func(s *Services) error { | ||||||
| 		s.AccountS = NewAccountService(s.Database) | 		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 | 		return nil | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
							
								
								
									
										13
									
								
								v1/main.go
									
									
									
									
									
								
							
							
						
						
									
										13
									
								
								v1/main.go
									
									
									
									
									
								
							|  | @ -2,7 +2,10 @@ package main | ||||||
| 
 | 
 | ||||||
| import ( | import ( | ||||||
| 	"embed" | 	"embed" | ||||||
|  | 	"net/http" | ||||||
|  | 	"strings" | ||||||
| 
 | 
 | ||||||
|  | 	"github.com/tylertravisty/rum-goggles/v1/internal/config" | ||||||
| 	"github.com/wailsapp/wails/v2" | 	"github.com/wailsapp/wails/v2" | ||||||
| 	"github.com/wailsapp/wails/v2/pkg/options" | 	"github.com/wailsapp/wails/v2/pkg/options" | ||||||
| 	"github.com/wailsapp/wails/v2/pkg/options/assetserver" | 	"github.com/wailsapp/wails/v2/pkg/options/assetserver" | ||||||
|  | @ -22,6 +25,7 @@ func main() { | ||||||
| 		Height: 768, | 		Height: 768, | ||||||
| 		AssetServer: &assetserver.Options{ | 		AssetServer: &assetserver.Options{ | ||||||
| 			Assets:  assets, | 			Assets:  assets, | ||||||
|  | 			Handler: http.HandlerFunc(GetImage), | ||||||
| 		}, | 		}, | ||||||
| 		BackgroundColour: &options.RGBA{R: 255, G: 255, B: 255, A: 255}, | 		BackgroundColour: &options.RGBA{R: 255, G: 255, B: 255, A: 255}, | ||||||
| 		OnShutdown:       app.shutdown, | 		OnShutdown:       app.shutdown, | ||||||
|  | @ -35,3 +39,12 @@ func main() { | ||||||
| 		println("Error:", err.Error()) | 		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) | ||||||
|  | } | ||||||
|  |  | ||||||
		Loading…
	
		Reference in a new issue
	
	 tyler
						tyler